Skip to content

Changelog

All notable changes to Lerd will be documented here.

The format follows Keep a Changelog. Lerd uses Semantic Versioning.


[1.31.0] - 2026-07-29

The 1.31.0 line is largely about the routes into lerd and out of it. Installing became one job again: an interactive install performs its privileged work through lerd bootstrap rather than its own handful of sudo calls, and a package maintainer script gets a fully unattended route through that same code, so a machine ends up in the same state however lerd got onto it. Node stops being an fnm-only story, with an existing nvm drivable as the version manager and host workers resolving whatever node the machine actually has rather than dying on npm: command not found. Databases keep moving out of Go and into the store: create, drop, export, import and the snapshot commands are declared in a service preset now, which hands MongoDB the whole Databases tab and lets RustFS list its buckets in the same grid, while a dump taken from a hosted provider imports without anyone having to have known the right pg_dump flags at the time it was taken. A JetBrains project gets its data source pointed at the right database on the right port, and every tool lerd downloads is pinned behind a published manifest so an upstream release cannot break a fresh install overnight.

Sharing a site is where most of the rest of it went. The public tunnels the CLI already had grew a dashboard menu, a base domain set once serves every Cloudflare share on a stable hostname instead of a fresh throwaway URL, ngrok runs from its published image on a machine that never installed it, and a share started in a terminal shows up in the dashboard like one of its own. The reason to share something is usually to have someone else look at it, which is why a running dev server is now served on the site's own domain: a Vite app used to advertise a localhost address that meant nothing to the visitor, so a shared or LAN-opened page arrived unstyled. The tools lerd pins also became something to act on rather than only read, with the pending version on each System card as the button that applies it.

A review of the whole cycle sits behind the security section. Three of those findings let a checked-out project decide what ran on the machine that linked it, through values it commits in its own .lerd.yaml, and all three predate this cycle, so they reach back past 1.30.1. The same review also closed a number of gaps in this cycle's own new work before any of it shipped, and those are described inside the features they belong to rather than listed as fixes, since no released version ever had them. What is under Fixed is what a 1.30.1 install actually runs into. macOS gets a pass of its own, closing the gaps between what a terminal can resolve and what the launchd daemons can.

Added

  • An assistant reaches a database engine's extensions, a service's other contents, and the Node manager (#1203). Three capabilities had no MCP tool at all. Postgres extensions were reachable only from the CLI, so an assistant could not see what an engine offers or add one before a dump arrives. Everything a preset declares that is not a database, the buckets a RustFS install holds, was reachable only from the dashboard. And the reference described the fnm and nvm split while offering nothing to act on it. The reference also now states what is deliberately absent, so an assistant stops looking: host tools are reported but applying an update is a CLI command, and sharing a site is not exposed at all, since putting a site on the public internet is the user's call.
  • Definitions for the framework versions that still run on PHP 7.4 (lerd-env/frameworks#30). Definitions covered only the recent majors, so a project on an older release borrowed the nearest one, which describes a different version. Laravel 6 through 9, Symfony 4 through 6, Drupal 8 and 9, Statamic 3 and 4, CakePHP 3 and CodeIgniter 3 now have their own. These ship from the store, so they reach an existing install without this release.
  • A site is shared through a public tunnel from the dashboard (#1116). The share button only ever started the LAN proxy, while the CLI could already tunnel a site through ngrok, cloudflared, Expose, serveo or localhost.run. The button now opens a menu on hover without giving up its one-click LAN toggle: a local network section mirrors the LAN state, and a public tunnel section starts any installed tool, with the same automatic pick a bare lerd share makes and disabled entries carrying install hints for the tools that are missing. lerd-ui owns the tunnel process, building the same invocation as the CLI through a shared command builder, running the tool headless and waiting for it to print its public URL, which then sits next to the domain with the same hover QR as the LAN link. Tunnels are deliberately not persisted: they end when they are stopped or when lerd-ui shuts down, and are never resurrected on restart.
  • lerd share --domain serves on a Cloudflare-managed hostname (#1106). A quick tunnel hands out a fresh random URL on every run, which is fine for showing someone a page and useless for a webhook target or an OAuth callback that has to keep working. --domain serves the site on a hostname whose DNS Cloudflare manages, creating a named tunnel, routing the record to it and reusing both on later runs so the URL never changes. Custom hostnames exist only for Cloudflare Tunnel, so the flag selects that tool on its own and outranks a default set with the new share:tool, while combining it with a different tool flag is rejected rather than silently overridden. Credentials for a tunnel created on another machine are checked before any public URL is announced, since the failure otherwise surfaced inside cloudflared after lerd had already promised an address that was never going to work.
  • A Cloudflare share is served on your own base domain (#1215). --domain fixes a quick tunnel's throwaway URL for a single run, at the cost of typing the whole hostname every time. A base domain settles it once: set one and every Cloudflare share is served on <site>.<base domain> through a named tunnel, with the label taken from the site's own domain rather than the folder the project happens to sit in. The dashboard asks the first time Cloudflare Tunnel is picked from the share menu, where a base domain can be typed or skipped for a quick tunnel, and ticking remember stops the question coming back whichever way it was answered, with the cog beside the entry reopening that dialog to change the domain later or clear it so the question returns. lerd share:domain is the terminal equivalent, and --domain still wins for a single run. A worktree's subdomain flattens into one label, so a branch is served on feat-login-myapp.example.com, because a Cloudflare certificate covers one level of subdomain and no more and the dotted form would fail TLS before it ever reached the tunnel. Named tunnels need cloudflared authorized once, which the dashboard cannot do because it has no terminal to run the browser login in, so it says what to run instead of hanging on a prompt nobody can see. The other half of this is that a share was only ever visible where it was started: a lerd share running in a terminal records itself for as long as it is up and clears the record on the way out, so the dashboard reflects it like one of its own and can stop it, a share killed outright has its record dropped once the process is noticed gone, and a site carries an icon in the list while it is shared, publicly or on the LAN, including when the share belongs to one of its worktrees.
  • ngrok runs from its published image when it is not installed (#1224). ngrok is the one tunnel tool with a published image, so lerd can run it under podman and a machine that never installed anything can still share through it. A container carries none of the host's ngrok configuration and ngrok refuses to open a session without a credential, so this needs an auth token: it is stored with lerd share:token, overridden for a single run with lerd share --token, and set from the cog beside ngrok in the dashboard's share menu, and it authenticates an installed ngrok that was never given one of its own too. An installed tool always wins, since pulling an image is the slower route to the same URL, and the image only stands in when nothing is installed, where it ranks ahead of the signup-free SSH tools because storing a token is a deliberate choice of ngrok. With no binary and no token the menu says so and offers the token, instead of sending the user to an install page they do not have to visit. The token is a credential and is handled as one: saving it tightens the config file to owner only, it reaches both the container and the binary through the environment rather than the command line so it cannot be read off ps, and the dashboard is told only whether one exists, never what it is. A container also does not die with the process that started it, because podman's supervisor is reparented out of the client's process tree and into its own cgroup, so the tunnel runs under a name of its own after the site and its branch: stopping it removes the container by that name rather than signalling a process, every start sweeps whatever a previous run left behind, a name left over from a crash is replaced rather than blocking the next start, and a running tunnel shows in the dashboard's resource usage alongside lerd's other containers.
  • A dev server is served on the site's own domain (#1217, #1222). A dev server advertises its own address, so a Vite app renders asset URLs pointing at localhost on a port only this machine can reach. Share the site through a tunnel, open it from the LAN, or front it on a worktree subdomain, and the visitor's browser resolves those URLs against their own machine and the page arrives unstyled, which the share proxy could not fix because it only rewrites the site's own domain and the dev server's URLs never carry it. Everything a supported dev server emits now lives under one base prefix that the site's own vhost proxies to it, so a single nginx location carries the assets and the hot-reload websocket alike and both travel on whatever hostname the visitor actually used, with nothing left to rewrite because the client derives its host, port and protocol from the URL it was loaded from. None of this is declared anywhere: a host worker qualifies when the project has the tool installed and the command starts it directly, following one level of package-manager script indirection, while a command that only reaches the tool through a runner is deliberately not matched since the appended flags would land on the wrong process. Nothing in the project is edited either. lerd writes a generated config next to the project's own, importing it and merging in the base, origin and allowed hosts for serve only, rewritten on every start since a worktree seeds node_modules from its parent by reflink and would otherwise inherit the parent's domain, and a project that tracks the generated path in git rather than ignoring it, or that has no config file for the tool, keeps its dev server exactly as it was. Every value reaching that file is encoded as a literal rather than interpolated between quotes the template supplies, and a quote is refused in a hostname when the site is registered, so the sink is not left as the only guard. The port has to be pinned because the vhost proxies to it, so it is kept clear of other sites, of the site's own worktrees and of whatever else the machine happens to be holding, re-picked when something has since taken it, and each worktree pins its own and takes its origin from its own subdomain. Plugin middleware that registers itself ahead of the tool's base handling only answers unprefixed and would 404 on URLs it advertised itself, so nginx retries any 404 under the prefix once with the prefix removed, which covers the plugins nobody has written yet.
  • nvm drives Node alongside the bundled fnm (#1112). Node version management was fnm and nothing else, so a developer who already keeps their versions in nvm had lerd download a second manager and install its own copies beside theirs. A node.manager setting now chooses between the two behind one interface, and every surface that touches Node, the CLI, host workers, shims, MCP, the TUI picker and the idle Vite builds, goes through the active manager rather than calling fnm directly. An install that finds nvm asks once whether lerd should manage Node, where yes keeps the bundled fnm and no hands Node back to the nvm that is already there, and the choice is switchable later with lerd node:manager or from the dashboard's Node page. lerd treats a user's nvm as read only: it never installs into it and never rewrites its default alias, keeping lerd's own default in lerd's config instead, and it leaves the node, npm and npx PATH shims out entirely under nvm, since a shim ahead of nvm made nvm use re-enter it and hang.
  • Package-manager installs set themselves up without prompting (#979). Debian and Ubuntu users install from a PPA, where a maintainer script runs as root and cannot answer the sudo prompts a normal lerd install relies on. lerd bootstrap --system performs the root-level, machine-global steps, the unprivileged-port sysctl, systemd linger and the DNS sudoers rule; lerd install --unattended runs the rest with no questions, defaults to managed .test and generates the mkcert CA needing no sudo; and lerd bootstrap --trust-ca installs that CA into the system trust store afterwards. The unattended path answers the Node question by detection rather than by reading shim state, which on a first install describes nothing, so a package install provisions Node instead of recording it as unmanaged and leaving the first command that needs a runtime to fetch one. Every unattended-only step is gated behind the flag, so an ordinary install is unchanged. The systemd unit templates no longer hardcode ~/.local/bin/lerd, so a daemon starts from wherever the package put the binary, lerd update recognises a binary under /usr and points at the package manager rather than fighting apt, and the trust step detects the anchor directory the way mkcert does across the Fedora, Debian, Arch and openSUSE layouts, reporting where the CA lives on a distribution with no writable store rather than failing the maintainer script. The user name the sudoers drop-in is written for becomes the first field of every rule in that file, so it is validated and the rendered file is put through visudo before it is installed, since a space alone is enough to produce a drop-in sudo rejects and lose the DNS grant it was meant to install.
  • A JetBrains project points at its own lerd database (#1134). An IDE cannot work out a site's database on its own, and the reason is lerd's: the site's env file names the container and its internal port, which is correct inside the network and unusable from the machine, where the same engine answers on loopback and whatever port it was published on. lerd now maintains one data source entry in the project's JetBrains XML, scoped to the project's own database rather than the whole server, written on link, refreshed by lerd env and the group commands, and removed by unlink, with the port read fresh each time so a second engine of the same family shifting ports cannot leave it stale. It detects and never creates: a project with no .idea directory is left alone, every data source lerd does not own comes back byte for byte, an entry whose database the project has stopped using is dropped, and a connection the user already wired to that database is theirs to keep. A sync that decides to change nothing writes nothing, rather than creating an empty credentials sidecar in a project it just left alone and moving the other file's mtime for no reason. Set ide_data_source: false in config.yaml to leave IDE files alone entirely.
  • An engine's entities and actions are declared in its preset (#1117, #1120, #1219). The Databases tab read its list from a preset, but everything else it did, create, drop, export, import and the snapshot commands, was a hardcoded mysql, mariadb or postgres switch in Go. An introspect block now declares entities, each naming the command that lists it with typed columns and the commands behind every action it supports, including which are destructive so the UI confirms first. One generic runner validates the entity name before substituting it into the declared command and derives the capability flags from what the engine actually declares, so a store engine gains any of these operations through a publish with no binary release. MongoDB picks up the whole Databases tab this way, and a service holding something other than databases declares it the same way: RustFS lists, creates and deletes buckets through an ephemeral client container, exporting a bucket as a tar.gz of its objects and loading it back from either form. The MCP db tool and the CLI export and import paths run through the same declared commands, which also fixes the MCP create path skipping declared extensions and its hardcoded container names making an alternate instance unreachable. The dashboard asks the engine rather than a list too: a service qualifies for the Databases tab when it belongs to a family lerd wires as a project database, as before, or when its preset declares databases it can enumerate, so an engine published in the store with a perfectly good introspect block no longer waits for a lerd release to become visible. What lerd may offer as a project's primary database is deliberately left alone, since an analytics column store has no business being offered there. Import honours the client image an engine declares the way export always did, so an engine whose own image ships no tooling can be loaded as well as dumped.
  • A dump from a managed provider imports without the flags nobody set at dump time (#1132). A dump taken from a hosted Postgres or MySQL arrived with hundreds of errors, none of them the user's mistake. One measured here reported 320, of which 80 were ownership: the dump assigns objects to roles that exist at the provider and nowhere else, and lerd's engines run a single admin role every preset points sites at, so those statements can never apply. The mysql families carry the same thing as a DEFINER clause, and worse, since running as root the object is created and only fails when something uses it. Both are filtered on the way in, which is what --no-owner --no-privileges does at dump time except the user does not have to have thought of it. Row data is never rewritten: a postgres COPY block passes through byte for byte between its header and its closing marker, and on mysql only DDL lines are touched, so a value holding the word survives. What was filtered rides back on the report and shows beside the errors, because a dump lerd quietly rewrote must never read as one that arrived clean. Loading into a database that already holds the objects is the other half and no filter can fix it, so import takes an empty-first choice in the dialog and --fresh on the CLI. On the dump that prompted this the two together took it from 90 errors to 11, with 44 of its 45 tables landing exactly.
  • An engine declares the extensions its image can create (#1137). A provider dump usually does not carry its extensions, because pg_dump skips the ones the connecting user does not own, so a vector column arrived with no CREATE EXTENSION anywhere, the table was rejected, everything depending on it failed, and nothing in the load named the extension that would have fixed it. A postgres engine now declares what its image can create along with the types each extension provides, so no extension name lives in Go. An entry marked always is created wherever lerd creates a database, so a database dropped and recreated by an import or a snapshot restore comes back whole rather than missing what the site was built on, and the rest wait until a dump names one of their types, which is how a geometry column brings PostGIS with it without every database on the machine paying the 15 MB for it. The same declaration backs db:extension list and db:extension add for when an extension is wanted before any dump arrives, and whatever was created rides back on the import report and shows on the card, since a database must never gain something without saying so.
  • A service dependency is satisfied by an installed drop-in (#1114). depends_on targeted a literal service name, so RedisInsight stayed blocked on a Valkey-only host and phpMyAdmin passed preflight against MariaDB and then failed to start against a mysql that was not there. Each entry now resolves to the named service or to any installed service whose family or env_role matches, and lerd starts that satisfier, preferring a same-family one over an env_role drop-in and a running one over a stopped one. A drop-in only counts when the consumer can actually wire it, so an admin tool cannot install green and then fail to connect, and an unmet dependency lists the store-declared alternatives rather than naming one engine. Connection hosts follow: an environment value naming the canonical lerd-<dep> host is rewritten to the satisfier at quadlet generation, so a preset keeps its plain lerd-redis in the store yaml and lands on lerd-valkey where Valkey is what satisfies redis, which also means a binary predating this parses the preset unchanged. That rewrite stops at a name boundary, so an environment value naming a versioned install of the same engine is not caught as a bare substring and left pointing at a host that does not exist. Matching dependents by family and env_role rather than by name alone also means a service whose own family satisfies one of its depends_on entries would be its own dependent, so the stop cascade skips itself instead of recursing without end. depends_on stays a plain list of names, so no on-disk YAML needs migrating.
  • expand_env writes one numbered variable per family member (#1147). discover_family and repeat_family both collapse a family into one comma-joined value, which fits phpMyAdmin's PMA_HOSTS and not a tool that reads numbered sets. RedisInsight is the case in point: it can preconfigure any number of databases through RI_REDIS_HOST_1 style groups, so a preset had to hardcode a single connection and an install running Valkey, or both engines side by side, opened the UI with those containers missing. A new top-level expand_env field writes one variable per running member, the declared key suffixed _1 to _N, expanding placeholders for the container hostname and the service name. It sits at the top level rather than inside dynamic_env on purpose: preset YAML parses non-strictly, so an older binary drops an unknown top-level field and keeps serving the preset's unsuffixed static keys as the single-connection fallback, where an unknown directive inside dynamic_env would have made every deployed binary refuse to start the service. A binary that does resolve expand_env deletes the unsuffixed key as it writes the numbered set, so the two never coexist in a rendered quadlet. Unknown directives are warned and skipped now rather than failing quadlet generation outright, which protects whatever comes next.
  • Host tools are pinned behind a published manifest, and every download retries (#1146). A momentary CDN hiccup could abort a whole install, since every binary lerd fetches went through a one-shot request with no retry and no timeout. Composer, fnm, mkcert, phpantom_lsp and the self-update archive now share a helper that retries transient failures with a short backoff and cancels a stalled transfer into the retry path instead of hanging forever, and install.sh gained the matching curl and wget flags. What a fresh install downloaded also depended on the day it ran, because fnm and mkcert came through GitHub's releases/latest redirect and composer through its stable channel, so an upstream release could break every new install overnight. The versions are pinned in a manifest now, embedded as the offline fallback and fetched from the repository with a daily cache, so a broken pin is fixable for every install with one commit and no binary release, and a scheduled job checks upstream daily and opens the bump PR. The managed tools are visible and updatable too: each download stamps a version sidecar, lerd status shows a Tools section, the web UI gains a System Tools page and a dashboard health row, MCP's diag status reports the same list, and lerd tools:update re-downloads exactly the tools whose installed version differs from the pin. Because the manifest is fetched from a branch rather than shipped in a release, so it reaches every install without going through one, a pin is honoured only when it points at a host lerd fetches tools from, may carry a sha256 the download is checked against, and lands in a temporary file moved into place only once complete, so a rejected or truncated download cannot replace a working binary. Composer reports a real version throughout: it is a phar and lerd has no host PHP to run it with, so the version is read out of the phar itself and stamped, rather than being known only when lerd was the one that downloaded it.
  • A host tool is updated from the dashboard (#1210, #1211). Reporting Composer, fnm and mkcert against the versions lerd pins and flagging the ones that differ still sent you to a terminal for a single command. The pending version on each card is the button that applies it now, and each card owns its result, which is what keeps this per tool rather than one button for all three: a download that fails its checksum is refused deliberately, and the card that asked has to name that tool rather than show a generic failure for the set. The pins come from a manifest cached for a day, so a newly published one could take that long to appear on its own, and the Tools page gains a check that re-reads it immediately. One check covers every card because the pins live in a single manifest, and a fetch that cannot reach the endpoint keeps the cached pins rather than falling back to the embedded ones, so being offline never reads as nothing to update. A tool that falls behind its pin also raises an update_available notification, the way a service and a PHP base image already do, riding the same status snapshot, firing once when a tool first goes stale and seeding itself quietly on the first snapshot of a process so a machine that is already behind does not announce everything at startup. The route replaces executables on the host's PATH, so it is restricted to loopback alongside the terminal and site-link routes rather than sitting behind LAN credentials alone.
  • A republished PHP base image surfaces as an update on the version (#1172). Base images are republished to the registry under the same recipe-hash tag whenever the upstream PHP image changes, and the client compared only recipe and custom-set hashes, so refreshed content under an old tag was invisible and an upstream PHP or Alpine fix reached an install only at the next event that moved a local hash, which for someone who never forces a rebuild is never. Every build now stamps the registry digest of the base it was built from, read straight from the registry so a refresh inside the cache window cannot stamp a digest the pulled base has already moved past, and a manifest lookup with no pull compares that against what the tag serves now, cached for six hours including failures so an unreachable registry is asked once per window. The status path only ever reads the cache and warms a cold entry in the background, so the dashboard never waits on the network and an offline machine stays quiet rather than reporting a false update. The version card carries the flag, the detail menu promotes the rebuild once the base has moved and always offers a manual check, doctor reports a stale base as a warning with the rebuild as its fix, and MCP's php_list flags it too. On the publishing side an upstream refresh now also rebuilds the hashes the two most recent stable releases resolve to, where before it froze every earlier tag at its last build and left a user one release behind pulling an unpatched base whatever they did.
  • The tinker split flips direction and goes full screen (#1115). The tab was a fixed 50/50 side-by-side split, which cramped long scripts that wanted height and wide dumps that wanted width. Two controls in the header fix that: a toggle between side by side and stacked, and a full screen button that expands both panes to fill the viewport. Escape exits full screen, the chosen direction persists across sessions, and the toggle is hidden on mobile where the panes already stack. Full screen names the site domain, and the branch when a worktree is selected, since the site header is hidden there.
  • The php.ini tab edits the version and the shared file side by side (#1121). It edited one file at a time behind a scope dropdown, and the two files work together, the shared one setting the baseline and the version one overriding it, so reading one while editing the other meant switching scope and reloading the editor, which dropped whatever was in the buffer. The tab splits vertically now, the version's own ini on the left and the shared ini on the right, stacking below the large breakpoint. Each pane owns its own load, dirty state, save, revert, reset and backup restore, so an action on one cannot touch unsaved edits in the other.

Security

  • A project decided what ran on the machine that linked it (#1203). Three values a repository controls through its committed .lerd.yaml reached a file lerd generates without being examined. A node_version reached the command line of a host worker unit, taken unquoted by fnm and interpolated into a single-quoted message by nvm. A custom_workers entry's label, restart and schedule each reached a line of that unit, where only the command was ever checked, and systemd executed an ExecStartPre one of them added. A domains entry reached the vhost's server_name, where a semicolon and a pair of braces close the server block and open another, and nginx accepted the result as valid, so a project served a block of its own on a hostname it chose. Values are now checked where the file is generated rather than field by field, so a field added later is covered without anyone having to remember. Paths are treated separately, since they reach the template quoted and a directory legitimately named with a semicolon still has to serve. All three predate this cycle.
  • postcss and dompurify carried published advisories (#1205). postcss allowed a path traversal through its previous-source-map auto loading, reaching both the dashboard build and the docs site, and dompurify let a custom element bypass afterSanitizeElements, reaching the dashboard through the Monaco editor behind the tinker and config editors.

Changed

  • Every project is registered through one policy-driven path (#1151, #1152, #1158). Registering a directory as a site was implemented three times: the full one behind lerd link, a thinner copy the parked-directory watcher used, and a third inside the MCP server that inlined the container and PHP paths. They had drifted badly. The MCP copy had no name deduplication, no domain-conflict fallback, no PHP clamping and no worktree check, and the park copy read nothing from .lerd.yaml beyond the domain list, so a project committing a public directory, a PHP version or its own runtime was registered as though it had said nothing. A linker package now owns the decision half and returns a plan a second step carries out, and what separates the callers is a policy rather than separate code: a link you type may prompt, write the version pin files, install services, issue a certificate and supervise a dev command the project declares, while an unattended one reads the same committed configuration and does none of that. Parking also writes each site's vhost and unit and performs the reloads that publish them once for the whole batch rather than per project, so a tree of fifty projects takes three seconds where it took ninety-two, and one of three hundred takes sixteen. A project declaring its own container, host proxy or per-site runtime is reported as skipped rather than quietly registered as plain FPM, since provisioning it means building an image or running a command the repository wrote.
  • The N+1 warning always names where the duplicate queries ran (#1171). The notification named a route only when the query events carried an HTTP request, so every CLI invocation, artisan command, tinker session and scheduled task fell back to "Ran a similar query 3x in one request", which left the most common console cases with a warning and nowhere to start looking. The bridge now stamps a command context on every CLI-SAPI process, the script basename plus its arguments, capped so a long argument list cannot bloat every event and with any single value over 32 characters elided to its flag so a tinker one-liner does not put its whole snippet in the notification body. The warning picks the most specific label available, worker, command, then request, and the dedup key gained the same arm, since otherwise every console invocation shared one key and only the first artisan command of a session could ever warn.
  • The tray menu groups into submenus and summarises the per-site workers (#1197). The services and PHP sections each carried their own fixed pool of menu items, a set of parallel arrays mirroring the rendered state, and a bank of click handlers that re-derived what to run from those mirrors, so the dot a service was drawn with and the command a click sent were decided in two different places. Both sections build rows carrying their own title and command now, so one generic list owns the pool, the redraw and the dispatch, and what a row does can no longer disagree with what it says. Entries past the pool size used to vanish without a trace and the last slot reports how many are not shown. Both lists move into submenus, taking the top level from around thirty five rows to twelve on a machine with the full stack installed, with the parent rows carrying the running service count and the current PHP default so the summary reads without opening anything. The status block gains a line summarising the per-site workers, which the menu filters out of the services list, warning from the worker health endpoint rather than from arithmetic on the running total, since a stopped worker is normal on a paused or idle-suspended site.

Fixed

  • A project older than any framework definition was refused the PHP it requires (#1202, #1203). Definitions exist for the recent majors, so an older project borrows the nearest and is marked as guessed. The version switch clamped to that borrowed range anyway, so lerd isolate 7.4 on a Laravel 8 application answered that 7.4 was not usable and moved it to the newest PHP installed, while linking the same project declined to clamp at all, leaving the two disagreeing about it. A definition now governs only when it is the real one for the detected version, and otherwise the project's own composer requirement decides, which also closes the case where a guessed definition meant no range at all and a project could be handed a PHP it cannot run. Alternation is read in either spelling: composer accepts a single pipe and the projects this matters for use it, Laravel 8 requiring ^7.3|^8.0, so the whole constraint was read as one term that nothing satisfied and detection fell through to its minimum, pinning 7.3 on a host running 8.5.
  • Every directory-scoped command dead-ended inside a git worktree (#1199). A worktree inherits its parent's registration and is deliberately never registered as a site of its own, but the shared resolver behind those commands looked a directory up by exact registry path, so inside a checkout it always missed. The command then offered to link the directory, link correctly refused because it is a worktree, and the caller reported "no site registered for this directory, run lerd link first", advice that can never succeed no matter how many times it is followed, taking out share, open, domain, env, worker, runtime, stripe and xdebug pause at once. The resolver falls back to the owning parent and the branch the checkout is on now, ahead of the link prompt. Commands that address a domain act on the branch rather than the parent: lerd share tunnels the worktree's own subdomain, which needed a branch dimension on the tunnel registry so a worktree tunnel neither replaces the parent's nor is stopped along with it, with the Cloudflare named tunnel keyed to the branch too so routing a hostname to a worktree cannot take over the one the parent uses, and the dashboard share menu, which hid its whole public tunnel section while a worktree tab was active, offers it and acts on that branch. lerd lan:share and lan:unshare resolved through a site name derived from the checkout directory and so assigned a LAN port to a site that does not exist, and drive the per-branch proxy that was already implemented now. Underneath all of it the worktree lookup compared raw paths where the registry lookup has always compared canonical ones, so on macOS and on ostree hosts, where a path is spelled two ways through a symlink, a checkout was not recognised as a worktree at all and linking inside one registered it as a separate site rather than refusing.
  • A secured site shared through ngrok or Expose sent the visitor to a .test domain (#1221). Those two were the tools aimed straight at nginx, with ngrok rewriting the Host header itself so the vhost would match, and for a secured site that is exactly where the public hostname goes: nginx answers the first request with a redirect to https on whatever Host it was given, which is the site's own .test domain, and the visitor lands somewhere that only resolves on the developer's machine, while pointing ngrok at the HTTPS port instead fails on the local mkcert certificate. Both go through the same local proxy Cloudflare and the SSH tools have always used now, which sets the Host nginx routes on, dials a secured site without tripping on its certificate, and rewrites the site's own domain out of redirects and asset URLs so the tunnel host survives. That rewrite had a gap of its own: it swapped the domain only where the URL appeared in its plain form, and JSON escapes its slashes, so an absolute URL embedded in a page payload or returned from an XHR reached the browser still pointing at a domain nobody else resolves. An external redirect does not always travel in a Location header either, since a framework can hand its own client one through a header of its own, so Content-Location and X-Inertia-Location are rewritten alongside it, and a rewritten URL now comes back over https rather than keeping the scheme it had locally, because the tunnel is always TLS and a plain-http URL on a page served over it is refused as mixed content. On macOS the container route needed the other half of it: the ngrok container ran with host networking so it could reach the local proxy on loopback, which holds on Linux where the container shares the host's own network namespace but not on macOS where podman runs inside a VM and the container lands in the VM's namespace, where nothing is listening, so every request came back as ngrok's own 8012 error.
  • A link damaged the framework definition it found, and could not run without a terminal (#1144, #1154, #1155, #1156, #1157). A project that commits its own framework definition could not be linked from anywhere without a terminal, because the prompt asking which definition to keep drove a TUI form with no fallback and failed with "could not open TTY", which took out the dashboard and the MCP action for any such project. That question already had a conservative answer, keeping both sides as they are, so that is what a caller with nobody to ask gets. Worse, detecting a framework wrote to the store: a project's committed definition was saved over the installed one on any call including calls that were only reading, and a project commits only the fields it cares about, so a partial copy left the store entry partial and a copy carrying no detection rules left the shared definition with none, after which nothing detected that framework for any project on the machine. A published definition is left alone now and its differences are the conflict prompt's to resolve, while a framework a project defines itself still propagates, because there the repository is the source of truth. Re-linking a secured project with no .lerd.yaml also dropped it back to HTTP, since an absent file reads exactly like a file asking for HTTP, so a link only ever turns HTTPS on now. The dashboard reported a link as a clean success when the environment step failed, and the database row in the summary read the backup taken moments earlier rather than the file just written, so a project configured for one database was reported as using the one it replaced.
  • Every install route applies its root-level setup through lerd bootstrap (#1193). An ordinary install did its privileged work through a handful of separate sudo calls while the deb package applied the same settings as root through lerd bootstrap, two implementations of the same four steps that had already drifted apart. The interactive install re-executes itself as sudo lerd bootstrap --system now and hands the mkcert system trust to sudo lerd bootstrap --trust-ca once the CA exists, so a machine ends up in the same state however lerd got onto it and all of the privileged work happens up front, which means someone who cannot use sudo finds out immediately rather than several minutes in. A host where the re-exec cannot run falls back to the old per-step path, and macOS keeps the behaviour it had. Uninstall gains the other half of it: the CA that bootstrap installs is named for lerd while uninstall removed the CA by calling mkcert -uninstall, which only recognises the filename mkcert itself wrote, so nothing ever deleted lerd's anchor and uninstalling left a trusted root certificate on the machine permanently. It is removed through bootstrap --untrust-ca now.
  • Uninstalling left the tray binary behind (#1230). lerd uninstall removed the binary it was running from and nothing else, while the shell uninstaller had always removed the tray alongside it, so the two routes disagreed about what an uninstall is. A tray can be started from a desktop entry rather than a unit, so what was left was not just a stray file but a lerd that could still be launched after the uninstall that was supposed to remove it. Both binaries go now, and a package-managed install still keeps its hands off both, since deleting a file out of a package's own file list leaves that manager believing lerd is installed.
  • The launchd daemons could not resolve what a terminal can, and Homebrew installs pinned themselves to a stale path (#1213). launchd hands lerd-ui and lerd-watcher a PATH of /usr/bin:/bin:/usr/sbin:/sbin, so a tool installed with Homebrew is invisible to them while it works fine in a terminal. The dashboard's share panel reported ngrok, cloudflared and Expose as not installed on every Mac that has them and the auto pick fell through to the ssh fallback, and the same blind spot decided which Node a host worker unit runs, so a unit written by the CLI resolved the Homebrew node while the same unit written by the dashboard or the watcher fell through to whatever nvm install happened to be lying around, which means a worker could run modules that lerd npm installed under a different Node, and a machine whose only Node came from Homebrew had its workers refused outright even though the unit's own PATH would have found it. Host binaries resolve through one place now, PATH first and then the install prefixes a daemon never sees, and the resolved path is what gets executed, since the binary has to be found at exec time too. A public tunnel could also outlive the process that owns it: systemd kills the control group and Pdeathsig covers a non-systemd run, but macOS has neither, and a tunnel is deliberately put in a process group of its own so stopping it takes the tool's children with it, so killing lerd-ui left the site public with nothing in the dashboard left to stop it. Tunnels are stopped from the shutdown handler now, and because SIGKILL cannot be caught, what is running is written down and reaped at the next start, signalling a pid only while the process under it is still the one that was recorded. Homebrew installs get three more: the daemon units pinned themselves to the versioned Cellar path, which the next brew upgrade deletes, so they keep Homebrew's own symlink instead; lerd update and lerd uninstall treated any binary under /usr/local as owned by a Linux package manager and offered an apt or dnf command that does not exist on a Mac, so those prefixes only count on Linux now and Homebrew gets brew upgrade and brew uninstall wording of its own; and an nvm installed with brew went undetected, because brew keeps nvm.sh under its own prefix and has the user leave NVM_DIR holding only the versions. Finally lerd install --unattended is refused off Linux, since it skips the sudo-gated steps because lerd bootstrap applies them as root around it, and bootstrap is Linux-only, so on macOS the flag quietly produced an install with no resolver grant and an untrusted CA.
  • Host worker units could not find a Node that lerd does not manage (#1143). When lerd does not manage Node, a host worker unit runs its command against a rebuilt minimal PATH, so a node living wherever a version manager put it, or under snap or linuxbrew, was invisible at runtime and every npm-based worker died on start with npm: command not found even though node worked fine in the user's terminal. The generators resolve where node and npm actually live before writing the unit, first on PATH while skipping lerd's own shims, then in the version manager layouts the installer already probes, then the static snap and linuxbrew directories, and bake that directory into the unit's PATH on Linux and into the launchd guard script on macOS. A location only wins outright when it covers npm too, so a bare distro node without npm cannot shadow a version manager install carrying the full toolchain, while a node-only directory is kept as a last resort so node server.js style host-proxy commands keep running. When nothing resolvable is found and the command actually needs the toolchain, the worker is held back with an actionable message instead of crash looping.
  • A changed worker unit was started rather than restarted (#1150). Change the dev command a host-proxy project declares in .lerd.yaml, re-link the site, and lerd reported the dev server as started while the process still running was the old command. The start path rewrites the unit, reloads the daemon, re-enables and then starts, which is a no-op on an already-active unit, so the running process was never replaced. A unit that changed and is already active is restarted now, and only when it actually changed, so an unchanged command does not churn a healthy process.
  • A PHP version typed as php84 was stored verbatim and broke every name derived from it (#1173). lerd use accepted it as given and wrote it straight into the global config, after which the pre-built base resolved to a repository the registry answers with 403 and the local fallback tried to pull an image tag that does not exist either. The command still reported success, so the install looked fine until the next image build failed. Version input goes through one normalizer now that reduces common spellings to major.minor, so php8.4, 84 and 8.4.7 all become 8.4 and anything that does not resolve to a supported version is rejected up front with the supported list in the error. It applies everywhere a version enters, and loading the global config repairs an already stored bad default in memory, so an install wedged by an earlier lerd use heals on the next command instead of requiring the user to know to run it again with the right spelling.
  • A store preset stayed stale in a running lerd-ui (#1182). Parsed presets are memoised in a cache only invalidated from the process that saved them, which holds for the embedded bundle and not for store presets, since lerd install, lerd service update and the daily refresh all rewrite those cache files from their own CLI process and a running daemon never noticed, serving the stale parse until the next restart. The cache entry records the file's mtime and size now and re-parses when either changes, and a file appearing for the first time invalidates naturally, which is the fresh-install ordering where the daemon starts before the store files are written and used to keep answering from the embed for the rest of its life.
  • Custom services inherited the host's /etc/hosts (#1105). Without an explicit mount podman derives a container's hosts file from the host's own, so whatever a developer keeps there is inherited by every service container, and a line mapping a service name to loopback shadows the container name the DNS would otherwise resolve. phpMyAdmin, pgAdmin and mongo-express all connect by container name, so they reached their own loopback and failed with a connection refused. PHP-FPM containers already mounted a lerd-managed file, so the two paths were inconsistent. Every custom service mounts the managed file now, pre-created before the quadlet is written since podman creates a directory at a missing mount source, and a service resolves its peers through container DNS rather than that file.
  • Resetting a non-default version's php.ini left its FPM unable to start (#1124, #1125, #1126). Every FPM quadlet bind-mounts the per-version user ini unconditionally, so once the reset deleted it podman refused to start the container with a statfs error and systemd restart-looped it until the start limit was hit. The reset re-seeds a commented template now, which is what "no per-version override" looks like on disk and keeps the mount valid, the same thing the shared file and the FrankenPHP site files already got. It hid on whichever version is the default, because lerd start rewrites that version's quadlet and re-seeds along the way, while any other installed version stayed dead until something happened to touch it. A reset whose restart fails now puts the contents back and applies again rather than leaving the version down with its settings deleted, both confirm dialogs raise a failure through the notification surface so a PHP version being down outlives the dialog that reported it, and the save and reset buttons name the restart while it runs instead of saying "Saving" through a multi-second wait.
  • An import lost every error past the fifth, and a gzipped dump was fed to the engine raw (#1130, #1133, #1135). A dump replayed over a schema that already had objects came back with 320 errors, of which the modal showed five and closed on a count of what it would not show, so everything past the fifth complaint was unreachable and the fifth is rarely the one that explains the load. The report keeps up to 500 distinct complaints now, which covers a dump that fails once per object, and the modal renders the full set. Separately a .sql.gz upload came back as a success carrying three complaints nobody could act on and left the database untouched, because the upload went straight to the engine client, which reads plain SQL, so the gzip header choked it and it exited clean. The stream is unwrapped before it reaches the client, in the one place the web upload, the CLI and the MCP tool all read. Exports also carry what snapshots have always carried: postgres dumps clean with --if-exists so a colleague who has already run their migrations does not drown in collisions, and the mysql families include routines, triggers and events, which mysqldump leaves out by default and which therefore never left the machine.
  • Dropping a postgres database lost a race with the site's own connection pool (#1140). Emptying a database before an import failed whenever the site was running, because the engine refuses to drop a database any session still has open. The drop already terminated other sessions first, so this was a race rather than a missing step: that session is the site's own pool, which reconnects the instant it is closed and is back before the drop lands, which made the two-step form fail precisely in the case where someone reaches for the option. One statement closes the sessions and drops with no gap to reconnect into, with the two-step form kept for servers too old to have it. Snapshot restore recreates the database through the same function and was losing the same race.
  • The COPY cascade went uncounted on postgres 18 (#1138). When a COPY block has no table to load into its rows are read as SQL and the engine complains once per line, and that flood is how the shape of a failed import is recognised, counted and folded into one entry so it cannot crowd out the statement that caused it. Dumps written by pg_dump 18 open with a restrict line, in which mode psql refuses backslash commands, so the rows that used to report an invalid command now say backslash commands are restricted and the tally recognised only the older phrasing. On a dump measured here the import reported 6 errors where 48 had gone past, with the 42 carrying the shape of the failure invisible. Both phrasings are the same cascade now.
  • lerd new looked composer up on the caller's PATH (#1153). It failed with "executable file not found" on a machine that has only lerd installed, even though lerd ships composer and running it through lerd in the same directory worked. lerd's composer is a shim in its own bin directory, so the old path worked only in a shell that had exported that directory and failed in a script, a systemd unit, an editor terminal that never sourced the profile, and the MCP server. Every create command in the framework store starts with composer, so this covered all of them, and the surrounding code already assumed the container, creating the target's parent and mounting it before scaffolding. A create command beginning with composer runs the bundled archive through the project's PHP container now, while one naming anything else still reaches the host binary, since a framework is free to declare something composer cannot provide.
  • lerd new refused a framework the store publishes but the machine had never installed (#1162). It resolved the framework through the installed-only lookup, so a definition lerd framework search finds was refused with an unknown framework error. Linking already fetches an uninstalled definition from the store, and scaffolding is the one command whose whole job is starting a project you have never built before, so it is exactly where the definition will not be local yet. The resolver falls back to the same store fetch linking uses, saves the fetched definition and re-resolves, for both the CLI and the MCP scaffold action, and a name the store does not publish keeps the original refusal.
  • lerd new scaffolded under a PHP version the framework does not support (#1165). The create command ran from the new project's parent directory, which is empty, so version resolution fell through to the machine default. Scaffolding a framework whose supported range tops out below that default left composer rejecting every candidate for requiring a PHP the run did not have: a create command that installs at scaffold time failed outright, while a no-install one reported success and left an empty vendor that only showed up as a 500 once the site was linked. The framework declares its supported range and is resolved before the scaffold runs, so the scaffold now runs under the default clamped into that range, through the same start-or-prompt path every other containerized command uses.
  • A link onto a PHP version this machine had never built answered 502 (#1164). It registered the site cleanly, wrote the version's FPM quadlet and reported that version, but never built its image or left a container running behind it, and because lerd php:list reads quadlets the freshly written one even made the version look installed. It is easy to reach without doing anything unusual, since an older framework clamps to the top of an older supported range and on a machine carrying only current versions there is nothing behind it. The link funnel checks whether the selected shared-FPM version has an image and builds it the way the install path does before the unit is started, streaming as its own link step. Where builds are withheld or a build fails the site is still registered and lerd names the command that finishes it rather than leaving a silent 502.
  • lerd framework add refused to install a published definition (#1163). After lerd framework search shows a definition in the store the obvious next command refused with an unclear "--public-dir is required", because add only ever authored a user-defined framework by hand, where the public directory has to be given since there is nothing to read it from. Installing a published definition was left to lerd framework update, which is not what its name suggests, and the MCP framework add had the same shape, quietly writing a hollow stub that shadowed the store definition. A bare name with none of the authoring flags installs from the store now, resolving the version from the project in the current directory when there is one and otherwise taking the latest, with name@version pinning one. Passing the authoring flags still authors by hand.
  • A never-migrated database reported unknown rather than a finding (#1149). A freshly linked project whose migrations have never run showed the migrations check as unknown with "Couldn't run the check, the app may be down or a dependency unreachable". Nothing is down: the status command simply exits non-zero saying the migration table is not there, and the catch-all collapsed every non-zero exit into that one sentence, which sent people looking at their database instead of at the migrate button the check already declares. A doctor command check can name a substring now that marks the finding triggered with its detail and fix ahead of the catch-all, so unknown keeps meaning a genuine connectivity problem and every framework gets the same escape hatch.
  • A client shim rejected a bare --version probe (#1128). Pointing an IDE at one was refused with "path to executable is wrong", because JetBrains validates a client executable by running it with --version and reading the output back, and with no host on the command line the shim prepends its own so a bare dump lands on the local database. The postgres tools answer --version and --help only as their very first argument, so what actually ran exited non-zero with nothing on stdout. A leading version or help flag is recognised as a probe and forwarded exactly as given, read from the arguments rather than the tool name so it holds for every shim a service exposes, and a probe no longer starts the service behind the tool when its image is already present, since printing a version string never needed a database to be up and an IDE runs that check whenever the dialog opens.
  • The Logs tab was hidden when a worker was a site's only log source (#1110). The site detail gated the tab on app logs, a PHP-FPM or container runtime, or a supervised dev server, so a site whose sole source is a background worker, such as a proxy-only host-proxy site running a Stripe listener, matched none of them even though the inner view would have built the sub-tab and the stream worked. Whether to offer the tab runs through one helper that mirrors the sources the logs view actually builds, running and failing workers included.
  • A proxy-only site showed as stopped while its dev server was up (#1108). The status dot is driven by the FPM running flag, which for a host-proxy site was computed only from its supervised dev-server unit, and proxy-only sites, where you start the server yourself and lerd only wires the proxy, have no such unit, so the dot stayed grey. For those sites the running state follows the proxied port now, a short probe reusing the same seam the worker health checks already use, while supervised host-proxy sites keep reflecting their worker unit exactly as before.
  • The service Databases and Contents tabs asked for a running engine to be started (#1186). The Databases tab read whether an engine was running off the engine it had fetched, so for the whole round trip of the databases request a running engine looked stopped and the tab offered to start it, and the Contents tab had the mirror problem and rendered an empty panel until its kinds arrived. Both take the running state from the service the dashboard already polls now and show a loading line while the request is out, a request that never lands says so with a retry instead of an empty grid, and a refresh after a create or a drop keeps the cards already on screen.
  • The worktree Based on picker did not stay on the branch you chose (#1226). The picker builds its first entry with an empty value, because an empty base ref means start from HEAD, and the dropdown decided what to render from the value alone, so that entry could never show its label: the field read Select... from the moment the modal opened and kept reading it after the current branch was picked, which made a perfectly good selection look like it had been ignored. The dropdown looks for a matching option first now and only falls back to the placeholder when nothing matches, which also brings back the All sites and All contexts labels on the dumps and queries filters. Editing the branch name refetches the form options, and that path treated a missing branch name as a first load, so it re-seeded the base ref, the build choice and the existing branch, and clearing the name field to retype it therefore threw away the base branch already chosen. Seeding belongs to the initial load alone now and a refetch only refreshes the option lists, with a sequence guard so a slow response for an older name cannot land on top of a newer one.

[1.30.1] - 2026-07-23

Two fixes: lerd start stopped prompting to reinstall the DNS sudoers rule on macOS every run, and the web UI's open-terminal button opens in the site directory again where ptyxis is the terminal.

Fixed

  • macOS prompted to reinstall the DNS sudoers rule on every lerd start (#1101). The passwordless-liveness probe that decides whether the drop-in is still active was hardcoded to resolvectl, a systemd tool that exists only on Linux, so on macOS sudo -n always refused with "a password is required", which the probe read as a conclusive "the grant is gone" and rewrote the rule, prompting for sudo, even when the installed /etc/sudoers.d/lerd was byte identical to its recorded hash. The probe command is platform specific now: Linux keeps resolvectl --version, macOS exercises mkdir -p /etc/resolver, a command the drop-in already grants verbatim and which is idempotent, so it succeeds silently when the grant is live and only reports it gone when sudo genuinely refuses.
  • The open-terminal button opened in the home directory on ptyxis (#1103). The web UI launched ptyxis with a bare --working-directory, but ptyxis is single-instance and applies that flag only alongside --new-window, --tab or -x, so it was ignored and a new window opened in the home directory instead of the site. The button passes --new-window now, so the terminal opens in the site or worktree path. Every other emulator was already correct and is unchanged.

[1.30.0] - 2026-07-22

The 1.30.0 line shrinks what a PHP version owns and widens what the dashboard can reach. Custom extensions and packages became one declared set applied to every image lerd builds, and php.ini follows with a shared scope that fills the gaps under each version's own file, so a site that changes version stops silently losing what the developer asked for. The dashboard manages an engine's databases from its service page with snapshots, export and import, offers to install a service the project asks for but never had, keeps every notification in a bell that survives a reload, renders log output in colour and hands any log to a terminal, and groups the services list by type. lerd doctor stops merely reporting and learns to repair what it safely can, notifications grow a native desktop sink alongside the new Lerd desktop app, and the terminal dashboard moves onto the Charm v2 stack. Git worktrees get a round of their own, resolving their own PHP version, their own image, their own pin and their own database from wherever a command is run. Underneath all of it there is a long tail of resolution and resource work: .test keeps answering with no network at all and comes back after a sleep, nginx stops rejecting a config over a directive lerd also declares, and the daemon stops paying for background work while nobody is looking at it.

Added

  • Custom PHP extensions and packages are one set across every version (#943). Extensions and packages were stored per PHP version, so a site that switched version silently lost the ones it had, an image built under 8.4 dropping chromium the moment the site moved to 8.3. They form one declared set now, applied to every image lerd builds, because they belong to the user rather than to a version. Because that set cannot always be honoured, lerd also records a realised set read back off each built image, so mongodb below 8.1 or an extension the legacy Alpine tiers cannot carry is reported as absent rather than falsely present. Rebuilds are lazy, fingerprinted by the declared set so a version rebuilds the next time it is used, changing a site's version funnels through one shared path so the CLI, the dashboard and MCP stay in step, php:ext list and php:pkg list report the declared set with per-version reality, and the dashboard gains a PHP Extensions tab.
  • A shared php.ini scope across every PHP version (#944). A shared 95-lerd-shared.ini mounts into every PHP container below the per-version 98-lerd-user.ini. conf.d loads alphabetically, so a per-version key still wins and the shared file fills the gaps, which means a site changing PHP version no longer silently loses a setting. The migration is additive, existing per-version files keep working and keep winning, nothing is merged, and the mount back-fills onto existing installs on the next quadlet write. An unknown or removed directive on a given version is a startup notice rather than a fatal, so a version-specific key never breaks the others. It is reachable as lerd php:ini shared, an Editing scope dropdown on each version's php.ini tab, and ini_read / ini_write / ini_reset with shared over MCP. The PHP page itself moves from a tab strip to a row of compact version cards, each showing the version with its full patch build, its status, and the default marker.
  • An engine's databases are managed from its service page (#966). Every database-level operation was reachable only from the CLI and the MCP db tool, so there was no way to see or act on your databases in the browser. Each database engine's detail page now opens on a Databases tab listing what is inside the running engine as cards with their sizes, and from a card you can create and drop a database, export it to a plain SQL dump or import one, copy a per-database connection string, and open it in an installed admin tool. Snapshots live on the card of the database they belong to, where you can take, restore, delete or download one, with named snapshots stamped with a UTC timestamp so they never collide. Cards link back to the site that owns a database, resolving a shared group database to the group main. The one new capability underneath is introspection, declared as an introspect.list_databases command in the engine's service preset and run generically, so nothing in Go branches on the engine name.
  • lerd doctor --fix repairs the environment (#958). Doctor told you what was wrong and left the fixing to you. Every finding now carries a fix in one of three tiers: auto is lerd's to run and needs no privilege, creating a missing directory, enabling linger, installing the network-online drop-in, rebuilding a missing PHP image, repairing the DNS wiring, and after confirmation the heavier reinstalls and disk reclaims; manual needs sudo, so lerd prints the exact command and never runs it; the rest is external state it leaves alone with its hint. Fixes confirm one at a time, the heavy ones confirm even under --yes, and --dry-run shows what would happen without touching anything. The MCP diag tool gains a doctor_fix action and reads the same structured report through a new doctor --json, so the checks and their fix tiers live in one place.
  • Native desktop notifications as an opt-in delivery sink (#968). Notifications only reached browsers, over the WebSocket to open dashboards and via Web Push to subscribed ones, so a user with no dashboard open and no push subscription got nothing even though the daemon runs the whole time. The lerd-ui daemon can now post straight to org.freedesktop.Notifications on the session bus, with no new dependency. Native is a mutually exclusive sink chosen by a new notifications.target setting so it never doubles up with browser delivery, an unset target resolves to browser so every upgrade and non-Linux install is unchanged, and a fresh Linux install asks which to use. Clicking a native notification opens the Lerd desktop app through its lerd:// scheme when installed, or an installed PWA, falling back to the dashboard otherwise.
  • The Lerd desktop app (#968). The desktop app lives at lerd-env/lerd-desktop and ships as a Flatpak, served from lerd.sh so flatpak install --user https://lerd.sh/lerd.flatpakref resolves and flatpak update keeps it current.
  • Notifications land in the dashboard, with a bell that keeps them (#1036, #1035). Notifications only ever existed on the desktop, so anything raised while the user was working inside lerd was either a popup for something already on screen or, once dismissed, gone entirely. Every notification now lands in the page as well: a toast appears in the corner, informational ones clearing themselves and a failure staying until dismissed with a link to what failed, shown even when notifications are muted since an operation the user started failing is not something to swallow. The bell in the sidebar holds the last fifty with an unread count, persists across reloads, and each entry links back to the thing it was about. Delivery also follows the window now. Each socket connection reports whether its window has focus, so while a dashboard is being looked at the desktop post and the Web Push are skipped and the event arrives in the page instead, and a restarted lerd-ui reloads any open dashboard rather than leaving it running an older build's assets against a newer server.
  • Log output renders in colour, and any log can be followed in a terminal (#1065). Log panes were monochrome, and the renderer was never the reason: workers and UI commands write to a pipe, a launchd file or the journal, so artisan, composer, vite and pest saw no terminal and stripped their own colour before lerd ever read the line. One definition of the force-colour environment now travels with every path that starts a worker or a command, on both platforms, and NO_COLOR turns all of it back off. The browser side gained the other half: the SGR parser covers backgrounds, dim, italic, underline, the 256 colour cube and 24 bit colour, and it collapses carriage-return progress frames the way a terminal does instead of concatenating every frame composer and npm emit. A terminal button in each pane header hands the stream to the daemon, which resolves it back to its unit and tails it with podman logs, tail or journalctl, so a long tail survives closing the tab.
  • A host-proxy dev server restarts from the dashboard (#1058). A host-proxy site's dev server is its whole runtime and bouncing it is the routine fix when it wedges, but the overflow menu's restart is for container sites, so the only route was a terminal. The site header now carries a reload button for host-proxy sites that run a command. Restarting also stopped trusting the unit restart: dev servers that drain queues on shutdown hold their port for a moment after the process is told to go, and re-execing in that window got the app killed by address already in use, so the restart waits for the unit to go inactive and the port to come free, says so when it is still held, and starts the server anyway rather than leaving the site with no runtime at all.
  • A missing service can be installed from the site overview (#946). A service listed in a project's .lerd.yaml that was never installed found nothing to join against and fell back to "Stopped", so it looked identical to one that is simply not running, and clicking it went to a Services tab entry that was not there. The card now shows an amber "Not installed", and clicking it offers to install the bundled preset inline, with a version dropdown for multi-version presets, jumping to the new service once it comes up. When no preset matches the name it says so and points at ~/.config/lerd/services instead. Reinstalling a removed built-in like redis works again too, where it used to fail with a name collision against the built-in it was trying to restore.
  • The services list groups by service type (#964). The Services panel rendered every core service as one flat list, which grew long once a project pulled in databases, caches, search and mail side by side. It now buckets them into labelled sections by category, in the same order the discovery grid already uses, with the per-site worker groups still listed below. The grouping is driven entirely by each service's category as resolved from its preset, so no service names are hardcoded.
  • Semantic highlighting in the tinker editor (#973). phpantom_lsp moves to 0.9.0, which brings tighter type checking, far better Laravel resolution for Eloquent builders, relations, macros and facades, new refactorings in the lightbulb, smarter completion ranking and mago.toml aware formatting, all through the existing wiring. The new client capability is semantic highlighting: the editor colours each token from what phpantom resolved it to rather than from the static PHP grammar, so a resolved method reads differently from an unresolved one and framework magic is coloured like the symbol behind it. It falls back to the plain grammar when the language server is unavailable.
  • Debug events captured during a test run are tagged and hidden by default (#1048). A feature suite fires hundreds of simulated requests, so a single run could push every genuine dump out of the buffer, and the only lever was disabling the bridge entirely before the run rather than after noticing the noise. Events captured inside a PHPUnit or Pest run now carry ctx.test, keyed on PHPUnit's own bootstrap constant so it holds for any framework and any runner built on it, set at all three capture seams. The lenses read a stream with those dropped and a Show test runs checkbox brings them back, reporting how many are held back while they are hidden, so a dump added inside a failing test that never appears has a visible explanation. Tagging rather than dropping matters here: dumping inside a failing test is arguably the moment dd() earns its keep.
  • The MCP db tool lists the databases an engine holds (#1007). The dashboard gained database introspection, but the db tool never picked the capability up, leaving an assistant able to create, export and snapshot a database while unable to ask what an engine actually holds. A list action closes that, resolving the engine the same way the snapshot actions do, so naming a service picks it explicitly and omitting it falls back to the project's own database. An engine whose preset declares no introspect command says so rather than failing.
  • The theme switcher is one icon with a mode menu (#1038). It was three stacked buttons labelled L, A and D, the letter-badge pattern the rest of the dashboard has moved away from, taking the vertical space of three icons to say one thing. It is now a single button showing the mode in effect, a sun, a moon or a half circle for following the system, opening a menu to pick between the three. On auto the tooltip names what the system resolved to, since the icon alone cannot say whether the page is currently light or dark.
  • A debug group header links back to the site the request came from (#1047). Spotting something in the system Debug window meant going back to the Sites list and finding that site by hand. The bracketed name is now a link to that site's own Debug tab. The label was built as one string by three near-identical copies of the same function, so the builder now lives once and returns the site, the branch and the trailing text as parts that one shared component renders for the dumps, queries and kind lenses.
  • Loopback-only pprof endpoints for profiling a running lerd-ui (#1076). There was no way to see where the daemon spends its time on a user's machine, so a CPU report could only be answered by counting forks and reading code. lerd-ui now serves the standard Go profiling endpoints behind two gates: a marker file under the run directory, read per request rather than at startup because the process worth profiling is usually one misbehaving right now, and loopback, since these endpoints hand out goroutine stacks, the command line and heap contents. Both answer 404 rather than 403, so a daemon with profiling off looks like one that has no profiling surface. The release binary is stripped, but passing it to go tool pprof alongside the capture resolves lerd's own symbols.
  • A host path can be bind-mounted into the containers (#954). A new mounts key in config.yaml mounts a specific host path into the PHP and nginx containers on top of the home and parked directories, overriding the denylist that keeps temporary system trees out by default, so an agent workflow can opt a scratch root under /tmp into the containers without lerd mounting every random temp path.

Changed

  • The daemon stops paying for background work nobody is looking at (#1073, #1074, #1079, #1075). Four background costs ran at full rate regardless of whether anyone had a dashboard open. The worker health detector shells out once per unit, so a 25 worker install paid 25 forks every five seconds for the life of the daemon; it now drops to sixty seconds while no window is visible and returns to five when one is, which keeps the failure notification the closed-dashboard user relies on while cutting idle CPU by roughly 83%. The dashboard polled /api/stats every five seconds against a three second cache, so every single poll missed and paid for a two second podman stats stream, and the TUI passed its own shorter TTL into the same shared cache and dragged the dashboard back down with it; the freshness window is now a property of the data, named once and honoured by both surfaces, and the share of wall clock spent streaming stats falls from 31% to 17%. The container cache went on polling podman ps forever after lerd stop, for containers deliberately down, and now settles on the post-teardown state and goes quiet until the marker clears. On macOS, enumerating unit states asked podman about each unit one at a time, which is memory-cheap inside lerd-ui and a VM round trip per unit everywhere else, so a worker health call over MCP took about 930ms on a 25 worker install; the sweep takes one snapshot up front and the same call now takes about 155ms with identical results.
  • A reload worker's file watcher polls once a second, and follows the power source (#1078). A worker opted into auto-reload runs chokidar in polling mode wherever the container cannot see host filesystem events, which is always the case on macOS and on WSL2 under /mnt, and chokidar's default interval re-stats every watched file ten times a second. On macOS each of those stats crosses virtiofs and is served by the host VM process, which held near 30% of a core for a single site and has been reported at 161%. The watcher now carries CHOKIDAR_INTERVAL, which chokidar honours however polling was switched on, so the vendored Octane and Horizon scripts are tuned without being patched. The interval tracks how the machine is powered, doubling on battery and again in low power mode, and a laptop unplugged mid-session has its polling workers restarted onto the new cadence rather than staying on the mains value until something else happens to restart them, held to one round every five minutes so plugging in and out does not bounce every worker. Power state is read over D-Bus from UPower and power-profiles-daemon first, since the sysfs file it used to key on is simply absent on much hardware, falling back to sysfs on a host with no bus at all.
  • The Debug view renders a page at a time and coalesces its stream (#1039, #1052). Every lens painted its entire filtered array in one go, and the client store holds up to 10000 events with a fresh connection replaying 3000, so a worker that had been running all afternoon put thousands of rows on screen. All three lens components now render the newest 100 rows and load the next 100 as the end of the list comes into view, which covers the system Debug window and every site's Debug tab across all eight tabs at once, with group headers still reporting a request's real size while its rows are partly windowed. Separately, every event arriving on the stream rewrote the shared store and changed its identity, so each of one PHP request's seven to eighteen events rebuilt every mounted lens from scratch. Events now land once per animation frame, and each event's search haystack and SQL fingerprint are computed once and cached weakly by event identity. Driving three hundred requests through a Laravel site with the Queries lens open, the tab went from thirteen seconds of blocking time and five frames a second to none and a steady sixty.
  • The terminal dashboard moves to the Charm v2 stack (#960). bubbletea, lipgloss, huh, glamour and bubblezone move onto their v2 line, alongside a refresh of the wider Go module graph. bubbletea's new renderer redraws differentially with far lower bandwidth, adds synchronized updates that stop the flicker, and handles wide characters and emoji correctly. lipgloss dropped the adaptive colour that resolved light against dark at render time, so the two-tone greys and the on-accent foreground now start from the dark palette and flip once bubbletea reports the terminal background. A dark terminal looks exactly as before.

Fixed

  • .test stopped resolving once the machine went offline (#936). On systemd-resolved hosts the resolver refuses to answer anything, over glibc NSS as well as resolvectl, once no link is routable, so a fresh .test lookup failed with no network at all even though lerd-dns kept answering on loopback. The tell was a site that stayed reachable while the browser held a cached answer but failed the moment it was reopened. lerd now owns an always-up dummy link carrying the routing domain, so resolved keeps forwarding .test to lerd-dns offline, and it removes the global drop-in it supersedes rather than leaving a catch-all that hung every ordinary offline lookup on an upstream that was not there. The link is best effort: a host that cannot carry it loses offline resolution and nothing else. FallbackDNS is turned off alongside it on the distributions that ship it populated, the configured dns.tld is validated before it reaches a root-owned unit, and every privileged step is covered by a grant so the headless watcher can apply it without a prompt.
  • A host waking from sleep could lose lerd-dns and leave .test dark (#1088). Nothing brought the container back, so every site on the local TLD stopped resolving until lerd start was run by hand, while the watcher logged "DNS resolution broken" every thirty seconds without being able to act on it. Its repair only ever rewrote the host resolver configuration, which cannot help when the daemon that configuration points at is gone. It now asks lerd's dnsmasq directly on its own port whether it is alive and restarts the unit when it is not, ahead of the privilege gate since that heal needs no sudo, and a dnsmasq that answers is left alone so the VPN case behaves exactly as before. The other half was systemd: the NetworkManager dispatcher restarts lerd-dns on every interface change, and a wake bringing wifi, ethernet and a VPN back within seconds exhausted the default start rate limit and parked the unit in failed permanently. The limit is lifted for this unit and the watcher clears leftover failed state before restarting, so an install already stuck recovers on its own.
  • Uninstalling left the whole root-owned DNS setup behind (#1022). The Linux uninstall stopped the user units and removed the binary but said nothing about the rest: the link unit stayed enabled, so every boot recreated lerd0 pointing .test at a dnsmasq that no longer existed, the resolved drop-in kept FallbackDNS empty for good, and the passwordless sudoers grant for a tool being removed stayed in place. Once the binary was gone nothing on the machine could undo any of it. Uninstall now offers to run lerd dns:disable while the binary is still there and prints the root commands to clear it by hand when that is declined or the binary has already gone. The fallback drop-in was also written under a guard that only covered the first run, so a host that had lerd0 and later lost it kept the drop-in from the run that worked and was left with resolved's fallbacks off and no link, which is exactly the state the guard exists to prevent; the failure path hands the fallback servers back now, on the grants-out-of-date path too.
  • The passwordless DNS grant outlived the tool, and a deleted one was never restored (#1098). /etc/sudoers.d/lerd is a standing NOPASSWD root grant covering resolvectl, restarting NetworkManager, and writing a root-run dispatcher script. Uninstall now removes it, so it lasts exactly as long as lerd does. Alongside that, lerd could no longer tell whether the grant was still there. It cannot read the drop-in back, since /etc/sudoers.d is root-only, so it keeps a content marker and asks sudo whether the grant is live, and only a conclusive refusal forces a rewrite. That question was being asked three broken ways at once. It ran sudo -l against the granted command, which reports whether the command is permitted rather than whether it runs without a password, so anyone carrying a broader rule got a success and, on the sudo Ubuntu 26.04 ships, so did everyone else. It matched only classic sudo's wording, not that same sudo's. And it read the refusal in whatever language the host speaks, so a Romanian machine never matched at all. Each one alone was enough to leave a deleted drop-in unrepaired while lerd advised running the command that had just failed to repair it. The probe runs the granted command now, in a pinned locale, and the drop-in permits its own removal so the teardown needs no password.
  • A site whose bind source had gone took the whole stack down (#1084). Podman refuses to start a container with a missing bind source, so a single project subdirectory removed by a branch checkout aborted the start with a statfs error, and because nginx serves every site that left no site reachable until the branch was checked out again. A start-time preflight now sweeps the quadlets, removes the self-mounts whose host path no longer exists and names the path and the site it belongs to, so the responsible project is obvious instead of buried in a journal. Only absolute-path self-mounts are considered, so the config, socket and ini mounts lerd creates on demand are left alone, and the path collection behind the quadlet rewrites drops missing candidates too, since otherwise a link elsewhere in the same session would re-add the dead path.
  • A directive lerd sets at http level could not be overridden (#1072, #1082). Setting client_max_body_size in the global http override made nginx refuse the entire config with "directive is duplicate": the override file is included inside http {} where lerd already sets it, and nginx fails on a repeated simple directive rather than letting the later one win, so every http-level default lerd ships was impossible to override no matter how the file was written. The generated nginx.conf now yields the field, reading the directive names declared at the top level of http.d and commenting its own default out for each one with a visible marker so the shipped value stays discoverable. It is name-driven rather than a list of special cases, and removing the override brings the defaults back. log_format and access_log are exempt, because they are repeatable and nginx is happy to carry both: commenting lerd's log_format out left its access_log naming a format nginx no longer knew, which failed the config check and took every site down, and that same feed is what idle-suspend and per-site request timing read.
  • A project outside the home directory was unreachable from the containers (#1054). Linking one wrote the bind mount into the PHP-FPM and nginx quadlets but left the containers running with the mounts they booted with, so the site path did not exist inside them and the first exec died with a crun chdir error. The rewrite that would have restarted them diffs its render against the file just written, finds no change, and restarts nothing, and the path guard skipped it for the same reason: the Volume line was right there in the file. Both now ask the running container what it is actually mounted before deciding, so a correct quadlet is no longer taken as proof. A stopped container is never treated as drifted since it picks the quadlet up when it next starts, a symlinked path is compared in resolved form so it cannot trigger a restart on every call, and per-site custom-FPM quadlets are covered, which were skipped entirely and left those sites unreachable outside home.
  • Worktrees resolved their parent site's PHP version, image and pin (#999, #1001, #1021, #1024). A git worktree checked out inside its parent's directory matches that site by path prefix, and four separate lookups stopped there. The CLI read the parent's registry version while the worktree's own vhost was generated from its pin, so the browser served the branch on one PHP version while lerd php and lerd composer in the same directory ran another. The container lookup missed for a sibling checkout and fell back to the shared per-version container, so a site running its own image from a project Containerfile had nothing that image adds present in the branch. Declining an install and picking another version from inside a worktree wrote .php-version at the parent root and left the branch with no pin at all. And a database client shim read the parent's DB_HOST, so a branch with an isolated database could be dumped from the parent's data and a branch on a different engine targeted the wrong container outright. All four now resolve the checkout the same way, and the rules behind them live in one place that the CLI and the MCP server both delegate to, so the version a command runs on and the version its container serves cannot drift apart again. Inherit-by-default is unchanged: a worktree with no pin still follows its parent. Switching a worktree's version also runs the same runtime setup a site switch does, where before it wrote the pins and the vhost and left a branch moved to a version with no quadlet on the machine pointing at a container that was never created.
  • The database endpoints trusted the names they were given, and answered the LAN (#1008, #1082). Both arrived with the Databases tab during the 1.30 betas and were fixed before this release, so no stable version carried either. The endpoints took the database and name values straight from the request while two sinks downstream assumed they had already been slugged: the snapshot handlers joined the database name into the snapshot path, so a traversing value resolved outside the snapshot root and reached os.RemoveAll on a real directory, with directory creation and arbitrary dump reads available the same way. Database names are checked against a strict pattern now, both at the HTTP boundary and inside the snapshot entry points so the MCP and CLI callers are covered by the same guard, and snapshot export runs its own name through it too. The existence lookup for MySQL and MariaDB also interpolated the name through an escape that doubled the quote but left the backslash alone, and MySQL treats a backslash as an escape unless NO_BACKSLASH_ESCAPES is set, so a backslash escaped the doubled quote and the rest of the value ran as a second statement; those two sinks double the backslash first now, while PostgreSQL keeps the old escape since standard_conforming_strings makes a backslash ordinary there. Separately the whole subtree was reachable from the LAN with remote-control credentials, so an authenticated client on the same network could export a database in full or drop it. A site's raw .env is already held to loopback for carrying the credentials to that data, and this surface both reads the data out and overwrites it, so it now sits behind the same gate and the tab is not offered on a LAN-exposed dashboard.
  • Values spliced into a translated message reached the browser unescaped (#1082). Translated messages are rendered through {@html}, and paraglide compiles a message to a plain template literal that escapes nothing, so the remote-control username, the LAN address, the setup code and the configured TLD all travelled that path verbatim. They are escaped where they are spliced in.
  • The shared ssh-agent crash-looped after any stop (#1067). ssh-agent refuses to bind a socket path that already exists and only unlinks it on SIGINT, SIGTERM or SIGHUP, never on the PHP image's SIGQUIT stop signal, so the socket survived every stop on its persistent volume and every subsequent start failed instantly and was respawned by Restart=always. Since lerd auth ssh recreates the container, the command that needs the agent was the one re-orphaning the socket, racing a window of tens of milliseconds and failing with "cannot exec in a container that is not running". The agent command is wrapped so the stale socket is removed before exec, making the container restart-safe however it was stopped. On macOS the argv read back out of a plist was not being unescaped even though the write path escaped properly, so any argument carrying an ampersand or an angle bracket reached podman still escaped, which broke that wrapper and the FrankenPHP worker exec alike.
  • lerd php:ext add failed for every extension on the default path (#1044). The fast path layers the custom extension block straight onto the pre-built base, which is the runtime image and carries no compilers, so pecl install got as far as phpize and stopped, while the same block on the local path sits in the builder stage where the toolchain already exists, which is why --local worked and the default did not. The fast path now installs the compilers as a virtual package, builds, and purges them inside the same layer, so the two paths agree and the runtime image stays the size it was. Nothing surfaced this, because each extension step ends in || true so that one entry a version genuinely cannot have does not fail the image for the versions that can, which absorbed a total failure with machinery meant for a partial one; an entry that landed on no version at all is reported as the build failing now rather than as a capability boundary.
  • lerd pest:browser install hung forever with no output (#1009). Playwright's Node-based zip extractor deadlocks partway through writing into the bind-mounted browser cache inside the FPM container, so every archive downloaded to 100 percent and then sat there with no error and no timeout, deterministically and regardless of size. The install now asks the project's Playwright where each component belongs and what to fetch with a dry run, downloads the archives with curl and unpacks them with the image's own unzip, keeping Playwright's mirror fallback for the components Microsoft mirrors and writing the registry link entry Playwright expects so a second site's install cannot reap the browsers this one just downloaded. Aborting used to make things worse, since Ctrl+C killed the host side only and left the in-container installer holding Playwright's install lock, so every retry blocked on it silently; the lock and any orphaned downloader are cleared before the install, after a failure and on interrupt, and the half-downloaded archives nothing ever cleaned up are reaped.
  • A Postgres import that half failed reported success (#1056). psql exits 0 whether a dump loaded cleanly or every statement in it failed, so an import that half landed looked identical to one that worked and its output went in the bin. Imports and restores now count what the engine wrote and keep its errors in the order it hit them, since the first failure is what causes the rest, folding the flood of unparsed COPY data that follows a missing table into one line so it cannot crowd the cause out. db:import, db:restore and a cross-version service migrate end on a warning instead of claiming the import completed, and the MCP restore tool passes the same summary back to whoever called it.
  • A migrate against a stopped or MariaDB service failed before touching anything (#1032, #1025). A service no site uses is auto-stopped, and that is the normal state to reach for the Migrate button from, but the migrate went straight to dumping, which execs into the container, so it failed on "no such container" and the notification reported a failed migration for a service that simply was not running. It brings the unit up first now and waits on the family's readiness probe. Separately, the mysql-family migrate spelled its dump as a literal mysqldump and its probe and restore as mysql with a fallback behind ||, and the MariaDB images carry only the mariadb-named binaries, so migrating a MariaDB service failed immediately on the dump. Those three in-container commands resolve their tool at runtime the way the snapshot and import paths already did.
  • lerd cleanup left several GB of unused service images unreclaimed (#981). Cleanup only treated a catalog service image as reclaimable when lerd's pull ledger recorded pulling it, but the ledger is written only in lerd's explicit pull wrapper, and a service quadlet starting for the first time has podman auto-pull the image without going through that path. Cleanup could report tens of MiB while several GB of old service versions and dropped services sat unreclaimed, visible only to podman system df. The deep tier now reclaims an unprotected, not-in-use image whose repo is in the service catalog even without a ledger entry, so a deep sweep recovers images pulled before this change, while the managed and safe tiers keep the ledger gate so the background sweep still never touches an image lerd cannot prove is its own. Service installs also record the image at the choke point every service quadlet passes through, so the ledger stays complete going forward.
  • Most worker failure notifications described a worker that had already recovered (#1018). Worker units carry Restart=always with a five second delay, so a worker that crashes once is usually active again within a cycle or two, and the watcher's five second tick plus a five second batch window put the notification right in the middle of the interval systemd uses to fix the thing itself. Arriving at a dashboard with a cleared banner and everything green teaches you to ignore the ones that matter. A new failure is held for thirty seconds now, still grouping anything else that trips in that window into one notification, and health is re-read before dispatch: workers that came back are dropped and the ones that stayed down carry the state read at flush time, so a unit that went from a crash loop to plain stopped is described as it actually is. A detector error keeps the whole batch, since failing to confirm is not evidence of recovery. The dashboard banner is a separate path and still appears immediately. The D-Bus calls behind all of this were also unbounded, so a wedged notification daemon blocked the calling goroutine forever, which reaches the mailpit webhook and the service install, migrate and reinstall handlers at one stuck goroutine per event, and the PHP install handler held a lock that left a version unable to be reinstalled without restarting lerd-ui. Every call gives up after three seconds, discovery no longer autostarts a dbus-daemon that outlives lerd over SSH, and the route map is capped rather than growing unbounded when the click listener cannot come up.
  • The DNS doctor failed a healthy lan:expose host (#1092). The rungs treated 127.0.0.1 as the only acceptable answer, so on a setup where dnsmasq legitimately answers the host LAN IP the doctor reported a failure while the TLD resolved and sites served correctly. The direct dnsmasq query and the end-to-end system lookup accept the LAN IP as well when lan:expose is on, derived the same way the probe already does it, and the two hints they print point at lerd dns:repair rather than a systemctl invocation that does not exist on macOS.
  • Debug events from a terminal command arrived with no site attached (#1041). The devtools extension reads LERD_SITE from the environment and lerd php never passed it, so every query, job and event raised by a terminal command reached the bridge unattributed while dumps quietly guessed a site from the directory name. lerd php forwards the registered site name now, with a worktree checkout reporting its parent the way tinker and the worktree vhost already do. Every debug notification also resolves its route the same way rather than each kind deciding for itself, so clicking one lands on the site's own Debug tab where the event and its context are, instead of a global view that says nothing about what was just clicked.
  • Idle-suspend put proxy-only sites to sleep (#1086). A proxy-only site forwards its domain to a dev server the user starts themselves, with no command for lerd to supervise, so there is no process the idle engine could stop and suspending it achieves nothing while still presenting it as asleep across the CLI and the dashboard. It is never suspended now, the same way a pinned site is not, a stale suspended list is still resumed rather than stranded, and lerd idle status reads "proxy only" instead of an idle duration it can never act on. A host-proxy site whose command lerd does supervise keeps sleeping exactly as before.
  • Every slow route offered to profile itself, on sites SPX cannot profile (#1060). SPX ships in the FPM image, so on a FrankenPHP site, a custom container, a host-proxy dev server or a static site the click armed the global profiler, opened the route, and left you in front of a profiler that would never receive a capture. The site payload now carries whether SPX can profile it, decided by the same rule the vhost rewrite uses to pick which sites get the SPX cookie, so the two cannot drift. Where it cannot, the slow routes still rank and read as before.
  • The site doctor opened onto nothing where no check could apply (#945). The doctor button showed on every site, but on a host-proxy running a Python, Ruby or Go dev server there is no framework, no composer.json and no package.json, so every check self-skipped and the modal opened empty. It now keys on whether any check applies rather than on the site type, which matters because a Node host-proxy has a package.json and still gets its dependency and audit checks. That predicate lives next to the doctor engine, so the dashboard, the CLI and MCP share one answer instead of each deciding for itself.
  • Unlinking a site left its request and idle state behind (#948). Unlink tore down the vhost, certs, workers, custom containers and registry entry but left the per-site state the watcher writes, so an unlinked site kept its entry in the request-timing and idle-activity snapshots and its recorded requests stayed in the durable store indefinitely. Nothing broke, since both snapshots are read by key lookup and a stale key is never resolved back into a site, but it was unbounded growth. The shared unlink path drops that state now, so the CLI, MCP and parked-watcher callers all get it, and a control-socket message tells the running watcher to forget its in-memory copy rather than write the files straight back on the next tick. Worktree keys are namespaced, so a site's worktree rows go too.
  • Parts of the dashboard rendered English whatever the language (#1081). The lerd-dns disabled notice, the no-DNS branch of the remote control description, the duplicate query tooltip, the TLS state labels on the site header and the disabled pill on the DNS panel were written straight into the markup, and the stores were the bigger source of it: every failed API call fell back to a raw English string, so a non-English user hit English the moment anything went wrong. Those resolve through the catalog now, with a shared set of keys for the fallbacks rather than the same message spelled out in sixteen stores. The catalogs themselves had drifted too. Key parity held, so nothing looked wrong from outside, but a large number of values were still sitting on their English source text, and all thirteen non-English catalogs are filled in against the terminology and register each had already established.
  • lerd php leaked a container error from an unreachable directory (#954). Running it from a directory the PHP container cannot reach, most often a scratch path under /tmp, passed the working directory straight through and let the runtime fail with an opaque chdir error. The shim checks the directory is reachable first and refuses with a message that points at the fix, the same guard lerd new already applies, and a plain data-file argument is no longer rewritten to /dev/stdin, so only the first script operand is streamed and a real path argument reaches the script unchanged.
  • The auto theme ignored a live system light and dark switch (#971). It only picked the change up on a full reload. The prefers-color-scheme listener re-set the theme store to its current value, which Svelte treats as a no-op and never notifies subscribers on, so the step that toggles the dark class never ran. The current theme is re-applied directly on the media change now, so auto follows the desktop live, in the browser and in the desktop app.
  • The dashboard cards left dead space instead of filling the window (#983). The cards were pinned to a 340px ceiling while the grid rows sized themselves to their content, so on a tall window the six cards stopped short and left a band of empty space under the second row. On the wide three column layout the grid now claims the height left below the header and splits it between its two rows, with the ceiling lifted so each card stretches into its share and the floor dropped so a short window shrinks the rows rather than clipping them. The narrower stacked layouts scroll as before and keep a floor instead, where filling the viewport would only squash the cards.

[1.29.0] - 2026-07-15

The 1.29.0 line pushes more of lerd's behaviour out of Go and into the stores: a framework definition can now declare the nginx config it needs, an env file that is a returned PHP array rather than a dotenv, the php.ini its CLI runs under, the services it cannot run without, and what its worktrees need to come up, while a service preset can declare its category, its icon, and the admin dashboard it fronts. On top of that, sites group into workspaces, worker self-heal learns to spot a worker that is running but no longer serving, Node projects are driven through whichever package manager they actually pin, and the dashboard and TUI both grow a round of request-timing and resource surfaces. The stable release also makes lerd a first-class citizen on atomic images: Fedora Silverblue, Bazzite and the other ostree spins boot and install cleanly, with the 90-second container-start stall gone and browser HTTPS trust explained rather than silently skipped.

Added

  • A captured query copies with its bindings inlined (#925). The Queries lens shows the SQL and its bindings separately, so copying the SQL alone left the ? placeholders unresolved. A copy button beside each query's duration resolves the positional placeholders into escaped SQL literals, strings quoted with their single quotes doubled, NULL for nulls, 1 and 0 for booleans and JSON for structured values, while question marks inside quoted strings are left alone, so the copied query runs in a SQL editor as-is.
  • An opt-in high-contrast tray icon (#917). The colour running icon adapts to the desktop's light or dark preference, but on mixed themes like KDE's Breeze Twilight, light application colours over a dark panel, that preference does not describe the panel and the icon disappears into it. A high-contrast style draws the running icon as a single green L that reads on any background the way the stopped icon already does. It is off by default, turned on with lerd tray icon high-contrast or the new tray menu item.
  • Sites group into workspaces (#827). A workspace is a named bucket of sites that survives across the dashboard, the overview grouping and the TUI's sort: workspace, so a machine carrying three clients' projects stops presenting them as one flat list. Distinct from site groups, which stay a domain-level concept.
  • A framework definition declares its nginx config and can keep its env in a returned PHP array (#845). A definition can carry an nginx: snippet spliced into the site's vhost, so a framework that needs its own asset or rewrite routes stops depending on lerd knowing its name, and php-array joins dotenv as an env format so frameworks whose config is a returned PHP array (Magento's env.php) are wired through the same env machinery as everyone else.
  • A framework definition declares the services it cannot run without (#847). A definition can list required services, and linking a project that needs them offers to install them rather than leaving the site to fail on first request.
  • A framework definition declares the php.ini its CLI needs (#848). The CLI container picks up the definition's php.cli_ini, so a framework that needs a different memory limit or a different set of directives than FPM gets it without a global override.
  • A framework definition declares what its worktrees need (#892). A definition can name the database isolation a worktree requires, what an isolated database is cloned from, and the console commands to run once the file and the database are in place. Magento asks for an isolated database cloned from main and for app:config:import, and its worktrees now come up on their own domain with nothing to run by hand, where before the store 500'd on a config hash the shared database disagreed with. Nothing in Go knows what any of those commands mean; they run through the framework's own console binary.
  • A service preset declares its category, its icon, and the service it administers (#852, #822, #825). Presets carry category, icon and admin_for, so an admin dashboard is presented as the front end for the service it fronts rather than as another unrelated tile. Installed services pick up the preset card shell, and the site overview's service cards offer to open a suggested admin dashboard directly.
  • Worker self-heal catches a worker that runs but has stopped serving (#841). A worker whose unit is happily active but whose dev server has died no longer reads as healthy: the probe dials the URL the worker publishes and flags it unreachable, and per-worktree worker units are covered by the same pass, so a dead per-worktree Vite is healed like any other.
  • Node projects run through the package manager they pin (#812). A project that pins pnpm or yarn, via packageManager or its lockfile, is driven through corepack with pnpm install --frozen-lockfile or yarn install --immutable instead of having npm forced on it. The setup wizard's step labels follow the manager it detected.
  • The dashboard shows reclaimable disk and cleans it up in place (#857). The resources widget carries a reclaimable-disk figure alongside CPU and memory, with a cleanup modal behind it, so the space lerd cleanup would reclaim is visible without running it first.
  • The sites list can be ordered by real request traffic (#859). Recently-used and most-used sort modes are backed by the request store, so the site being worked on surfaces without being pinned by hand.
  • The site Env tab names the file it is editing (#865, #858). The toolbar shows the dotenv path the framework actually resolves to, which for a worktree is the worktree's own file, so it is no longer ambiguous which env a change is landing in.
  • The TUI grows a request-timing pane, a dedicated logs tab, and a responsive site overview (#868, #872). Request timing joins the site overview, logs get a tab of their own rather than sharing a pane, and the overview lays out as a grid that reflows with the terminal.
  • An AI assistant can reach workspaces, and knows what a service preset administers. MCP gains a twelfth grouped tool, workspace (list, create, rename, delete, assign, move), so an assistant can organise a site list rather than only read it, and preset_list now carries each preset's category, icon and admin_for, which is what makes "which dashboard administers this database" answerable: phpMyAdmin administers mariadb as well as mysql, and that is not the same relationship as depending on it. The MCP handshake reports lerd's real version instead of a hardcoded one, and the reference every assistant reads no longer describes lerd as a Laravel-only environment or claims that Node work runs through npm.

Fixed

  • lerd runs cleanly on Fedora Silverblue and other atomic images (#928). Podman's quadlet generator makes every rootless container wait on network-online.target, which nothing activates on atomic images, so it timed out after 90 seconds and dragged every container start and the whole boot out with it. lerd now writes a drop-in that turns that wait into a no-op on hosts where the target never comes up, gated so ordinary distributions are untouched, since lerd only ever binds loopback. The system tray was the other half: lerd-tray links a library these images do not carry, so install now leaves the tray unit stopped and disabled when its libraries will not resolve rather than letting a failed unit leave the systemd user session degraded, and lerd doctor reports both conditions.
  • Missing certutil no longer leaves .test HTTPS silently broken in the browser (#931). On atomic images certutil cannot be installed without layering a package, so mkcert trusts the CA in the system store but skips the browser's NSS databases and only warns, a warning lerd swallowed, leaving certificates that worked on the command line but threw errors in the browser. lerd now surfaces the missing certutil during install, dns:enable and lerd doctor, explaining that curl, PHP and openssl trust .test while the browser will not, and pointing at either layering nss-tools then running dns:repair or falling back to .localhost over http. The bootstrap installer learns the same, consulting ID_LIKE so an atomic spin like Bazzite is recognised instead of dying on a package install that cannot succeed.
  • A project no longer lists twice on atomic images (#932). On atomic images /home is a symlink to /var/home, so the working directory can come back under either spelling, and linking a project through each registered it as two separate sites, the second disambiguated with a numeric suffix. Paths are resolved before they are stored and compared now, so the two spellings collapse to a single site, and an entry left over under the unresolved spelling still matches.
  • Profiling a slowest route on a localhost site opens over http (#935). The per-site timing overview built the profile URL with a hardcoded https scheme, so profiling a route on a .localhost site, which lerd serves over plain http with no certificate, failed with a certificate error. The URL follows the site's TLS state now, http for an unsecured localhost site, https for a secured .test site.
  • lerd check stops flagging the Stripe and host-proxy workers (#927). check validated every worker in a site's list against the framework's definitions and failed for anything else, so the Stripe listener and the host-proxy dev server, which legitimately run outside a framework, reported a red error on a healthy site. check recognises the built-in workers now through the same shared helper the rest of lerd already uses, while a genuinely undefined worker still fails.
  • .lerd.yaml is written atomically (#924). The file was written with a bare, non-atomic write, so a process killed mid-write or two callers racing could leave it truncated, and since most readers treat invalid YAML like a missing file, a site would silently lose its pinned versions, domains, services and workers. The write goes through the package's atomic writer now, and the output is normalised to a stable, canonically-ordered form so a worker starting or stopping produces a minimal git diff instead of a reshuffled block.
  • Console commands run inside git worktrees (#921). lerd artisan and lerd console looked the site up by an exact path match, and a worktree checkout is never registered as its own site, so they failed with "no framework assigned" even on a served worktree whose parent was linked. They inherit the parent site's framework for worktree paths now, the same way the worker commands and lerd tinker already do, and the MCP console tool is fixed with them.
  • The CLI accent follows the terminal theme (#919). The CLI and TUI shared a palette whose interactive accent was the brand red, the same family as the failure colour, so a running spinner, an accented value or a focused border all read as an error. The signal colours use ANSI terminal-theme codes now, so the accent and running status render as the terminal's green, failures as its red and warnings as its yellow, following whatever theme the user runs, while the web UI keeps its brand red.
  • A Symfony project had its service credentials written into the git-tracked .env (#883, #885). The env wiring and the db commands still resolved the Go built-in framework definition while the doctor and the UI had moved to the store-first, version-aware resolver, and for Symfony the two disagreed: Symfony commits .env and gitignores .env.local as the local override, so setup saw a committed .env, took the updating branch, and wrote DATABASE_URL and the rest of the credentials into the tracked file, never creating .env.local. Meanwhile the Env tab and the doctor looked for .env.local, did not find it, and reported the site as unconfigured. Both call sites resolve through the same definition as everything else now, and worktree env sync follows the same resolver.
  • A worktree 404'd on every framework that does not serve from public (#878). A worktree vhost hardcoded its document root to public, so a Magento (pub), Drupal (web), CakePHP (webroot) or WordPress worktree answered every request with a 404. The worktree generators run the parent site through the same resolvers the main vhosts already use, so the root follows the framework's public_dir.
  • Resolving a framework wrote to the project (#906). The resolver repinned framework_version in .lerd.yaml whenever composer disagreed with the file, and it is reached from vhost rendering, dashboard polls, the TUI and the watcher, so a checkout found its committed .lerd.yaml modified with no command having been run. It is a read-only resolver now, and the commands that own the file sync the version explicitly.
  • A scheduled worker was shown as stopped (#850). A worker with a schedule is a oneshot service driven by a sibling timer, so its service unit is inactive between ticks and worker status, reading the service, drew a perfectly healthy Laravel scheduler or Magento cron as stopped. Liveness for a scheduled worker is now the timer's state, while a failed last run still surfaces from the service.
  • A worktree's worker was reported against the wrong site (#906). Worker units were resolved by the longest matching site suffix before their working directory was consulted, so a worktree checked out into a directory named after another registered site was attributed to that site, under a worker it does not have. The checkout settles it first.
  • The PHP images advertised extensions they did not load (#889, #887). BundledExtensions is what lerd tells a project it already has, and nothing could contradict it, which is how ext-ftp survived as an advertised extension nothing installed. The base-image build now compares the whole advertised set against php -m in the image it just built, which turned up two more of the same bug: ext-random is core only from 8.2, and PECL mongodb no longer builds below 8.1, so three of the seven published images did not load something the list promised. The advertised set resolves per PHP version, an extension a site's PHP cannot have is explained rather than offered for install, and composer's ext-zend-opcache resolves against the bundled opcache instead of failing a satisfiable requirement.
  • The CLI ran on a different PHP version than the site's FPM served (#889). lerd link clamps a site's PHP to the framework's supported range, but composer, console, php and php:shell each re-detected the version from the directory, so a project pinning .php-version to 8.1 could be served by 8.5 while composer ran inside an 8.1 container it started for itself. They resolve the registered site's version now, and link writes the resolved version back into .php-version rather than leaving the pin contradicting reality.
  • A commented-out line in .env kept a service listed as permanently stopped. A site's services are enumerated from container hostname references in its .env, and the match scanned the file as one blob, so a disabled #DB_HOST=lerd-mysql, or a live line carrying a trailing # was lerd-mysql comment, still counted as a live reference and left a grey Stopped badge that never went away. Commented lines and trailing comments are skipped, while a # inside a value is preserved.
  • The services list showed presets that were removed or never installed. A default preset dropped from the store lingered in a site's service list, and default-preset services that are not installed were listed alongside the ones that are.
  • A site left on the old TLD by a declined rewrite lost HTTPS on re-enable. Re-enabling such a site did not restore its certificate, so it came back on plain HTTP.
  • Containers resolved a stale nginx address after a restart (#818). When nginx came back on a new IP the container hosts files still carried the old one, so requests between containers went nowhere until the next full restart.
  • A group's wildcard certificate could serve a secondary's subdomain (#819). A secondary site in a group is secured on its own so the main site's wildcard cannot answer for it.
  • Request timing counted WebSocket upgrades and reported the wrong p50 (#834, #835, #861). Long-lived WebSocket upgrades were recorded as ordinary requests and dominated the timing view, the live snapshot reported something other than the real p50, and a worktree's requests were neither recorded nor read back.
  • A site whose path contains a space would not serve (#896).
  • lerd new leaked its own flags into the scaffold command (#897). The flags lerd parses for itself were passed through to the underlying scaffolder, and the target directory was not mounted into the container that runs it.
  • A rewritten nginx quadlet dropped mounts for projects outside $HOME (#903). Regenerating the unit lost the out-of-home bind mounts, so a site living outside the home directory stopped serving until it was relinked.
  • lerd site:doctor ignored --silent, mis-reported severity, and did not confirm its fixes (#901). It also named only the first service that was down rather than every one of them.
  • Installs fell back to a slow from-source PHP build (#880). The base-image manifest job ran only when the entire build matrix succeeded, so a single arch or version failure skipped manifest creation for all seven PHP versions, and with the arch-less tag unpublished there was no prebuilt base for the current Containerfile hash. Manifests are published per version now, and a version missing an arch fails its own cell with a clear message instead of quietly leaving the tag unpublished.
  • The rustfs readiness probe checked the wrong port (#882).
  • Unlink and worktree database drop asked for confirmation in a native browser dialog (#871). Both now confirm in a lerd modal, consistent with every other destructive action in the dashboard.
  • The watcher could exit with the idle engine's work still in flight (#862).
  • A wrapped translation broke the star card's sentence (#910).

Security

  • A project path could bind-mount the filesystem root into containers (#886). A crafted or malformed path resolved to /, which was then mounted into the site's containers. The root is never mounted now, whatever the path resolves to.

[1.28.1] - 2026-07-08

A patch release that gets the v1.28.0 max_allowed_packet change to actually reach running services on update, and hardens the framework and service store against transient fetch failures.

Fixed

  • A shipped preset config change reaches a running service on update, not only on reinstall (#805). The v1.28.0 bump of MySQL's max_allowed_packet only took effect after an explicit lerd service reinstall, because the reconcile pass regenerated a service's config files only when its unit was missing and never restarted a running container. Reconcile now re-materialises a running service's preset files and restarts the container when its config file is newer than the container's boot, meaning the config genuinely drifted, so a shipped preset config change lands on a plain lerd update. The mod time only advances on a real content change, so a steady state restarts nothing, and the same drift check covers both the default stack and store services.
  • A transient store fetch is retried instead of dropping a definition (#807). Refreshing the framework or service store fetches each definition over its own request, and a single slow raw.githubusercontent.com response would fail that entry with a context-deadline error and leave it stale until the next refresh. A fetch now retries a transient failure, a request timeout, a dropped connection, or a 5xx, up to three attempts with a short backoff, while still giving up immediately on a definitive 4xx such as a removed definition.

Changed

  • Go dependencies refreshed (#806). golang.org/x/net moves to v0.56.0 and the module graph is re-tidied, clearing stale Dependabot alerts that lingered against already-patched versions.

[1.28.0] - 2026-07-08

This release turns the per-request signal lerd already carried into a full request-timing analytics view for every site, moves service presets to the same external store frameworks already use, and rounds out the AI diagnosis surface so an assistant can find a slow route, its N+1 queries, and its CPU hotspots in one call. Alongside that, TLS certificates renew themselves before they expire, DNS switches on and off through dedicated commands, database client tools become host shims, lerd cleanup reclaims real disk by default, the site page is rebuilt as a resource view, and the dashboard grows a QR code, a mobile session, and a round of phone polish.

Added

  • Per-site request timing analytics (#723, #796). The nginx access feed, previously starved down to the request host for idle-suspend, now carries status, request time, method and URI, and a framework-agnostic aggregator keeps a rolling per-site, per-route window off that same feed. The site Overview gains a Request timing section driven by a durable SQLite record: a range picker, KPIs for the typical and p95 response, request count and error rate, a response-time distribution, a throughput chart, the slowest routes with one-click Profile, and routes and recent-requests panels. Routes are grouped after collapsing id-like path segments and dropping query strings, and a route is flagged when its p95 runs well above the site's own typical response and it has cleared a minimum sample count. The first request after a site has sat idle past the suspend timeout is recorded with a cold flag and kept out of every timing figure while still counting toward the total. The same finding reaches you without the panel open, as a Response Time warning in the site doctor that the CLI, the dashboard, and MCP all inherit, and as an opt-in slow_route push notification that fires once when a route crosses the threshold and rearms when it drops back.
  • AI can diagnose a slow site from real traffic (#725, #727). MCP diag route_timing returns the per-site response-time table read from the timing snapshot, and optimize_route joins each slow route to the N+1 and slow queries captured behind it, with the caller file:line, plus a profile block of the top functions by exclusive wall time distilled from the SPX capture of that route, so a CPU-bound route shows where its time went next to its queries. A new spxreport package parses SPX's on-disk trace into a flat profile and keeps only the outliers, the same distilled hotspots lerd profile run --flat and profiler_report return for a command.
  • Service presets move to an external store (#704, #713). Service presets adopt the fetch-and-cache store model frameworks already use. The binary embeds only the default stack (mysql, redis, postgres, meilisearch, rustfs, mailpit); every add-on lives in the lerd-env/services store, fetched on demand into a local cache, refreshed opportunistically after 24 hours, with the embedded defaults kept as an offline fallback so older installs keep resolving. A new lerd service search browses the store and lerd service preset installs a store-only preset, the preset picker in the CLI and web UI merges the local presets with the store index, and a reconcile backfill fetches the definitions of already-installed add-ons on start so their file mounts, families and dashboards survive the move out of the binary. Presets can declare config-file mounts in YAML, inline or via a named generator, which is what keeps phpMyAdmin and pgAdmin auto-login and the RedisInsight and RabbitMQ dashboard proxies working from the store.
  • Database client tools as host shims (#750). The client tools that ship inside each database service image (mysqldump, pg_dump, psql, redis-cli, mongodump and friends) are surfaced as pass-through shims on the host PATH, so you can dump or query a database that lives outside lerd or point an IDE at a real client binary without installing the tool yourself. Each shim runs the tool in a throwaway container built from the service image with your home directory mounted, so output files and CA certs behave like a native binary and the running database container is never touched. A hostless dump adopts the current project's own database service; an explicit host passes straight through. lerd shims lists and toggles them, and each database service gains a Tools tab in the web UI.
  • TLS certificates renew before they expire (#729). IssueCert is now expiry-aware and keeps an existing certificate only while it still parses, is valid, and sits more than thirty days from expiry, otherwise it reissues; the watcher boot scan runs a new EnsureCert for secured sites so an ordinary lerd start heals an aging cert, covering the site's own domains and every worktree wildcard SAN. lerd secure --renew force-reissues on demand without toggling HTTPS off and on, with a matching tls_renew action on the site MCP tool.
  • DNS switches through dedicated commands (#745). The install-time DNS question is asked once, on a genuine first install, and every later install or update honours the saved choice silently, the same way the Node-management choice does. Flipping the mode moves to three commands: lerd dns:enable turns lerd-managed DNS on, lerd dns:disable turns it off and moves sites to localhost, and lerd dns:repair re-runs the setup to fix a broken but enabled resolver.
  • Extra host ports per PHP version (#748). Each PHP version carries its own list of extra host ports, published on that version's shared FPM container so localhost:PORT reaches a process you start in lerd shell, a Vite dev server, a websocket, an ad-hoc listener. A requested port already claimed by a lerd service, another version's list, or any other listener shifts to the next free port. A Ports tab on each PHP version adds and deletes ports as cards, lerd php:ports add|remove|list covers the CLI, and the MCP runtime tool gains ports_list, ports_add and ports_remove.
  • Insert missing .env keys in place (#717, #734). An anchor-based merge fills in the keys a project's .env is missing from .env.example, placing each next to the neighbours it has in the example and copying the example value verbatim, purely additively. lerd env:check --fix shows a unified diff and applies it on confirmation; the dashboard Env tab opens a Review keys modal listing each missing key with the value it would get and a checkbox per row, required keys pre-ticked, so you stage exactly the subset you want into the editor with green change bars before saving.
  • lerd cleanup reclaims real disk by default (#739). The deep tier is now the default, for the command and the daily sweep, with a --safe flag to drop back to the conservative provably-lerd-only pass. Deep reaps catalog service images no service references any more and every dangling image, retries removal across passes so a build chain reclaims in one run, skips an image a container still holds, and when everything reclaimable is held by a running container reports how much a restart would free instead of a bare nothing to do.
  • Remote dashboard address, mobile sign-in, and phone polish (#782, #784). The Remote dashboard access card shows the http://<lan-ip>:7073 address next to a scannable QR of the same URL once LAN exposure is on. Remote access gains a session layer: on the first successful Basic auth the gate sets an HttpOnly, HMAC-signed lerd_session cookie valid for a week, so iOS Safari stops re-prompting for the password on every refresh, and rotating the credentials invalidates every session. The web UI picks up a round of phone polish, wrapping toolbars, a slimmer back bar, the framework badge hidden on narrow screens, the open action targeting the LAN share URL on a remote dashboard where .test does not resolve, Tinker no longer zooming on iOS focus, and the loopback-only launchers dropping out when the dashboard is viewed remotely.
  • Themed hover tooltips across the web UI (#780). A shared use:tooltip action renders one themed bubble portaled to the body, so tooltips escape the panels and horizontally scrolling rows that clipped the native title labels, follow the light or dark theme, and clamp to the viewport. Everything that carried a title flows through it while keeping an aria-label.
  • The site detail page is a resource view (#793). The Overview becomes a stack of sections rather than one control strip over the logs, a runtime and workers row carrying the PHP/Node pickers, the worker toggles, and the doctor and framework commands as a matched pair, and a grid of service cards each with its category icon, running state, and a button that opens the service's dashboard. Live logs move to their own tab, the project path below the header opens the folder in the host file manager on a loopback dashboard, and the doctor stays worktree aware.
  • queue:start is derived from the framework definition (#769). A tune_command template with {queue}/{tries}/{timeout} placeholders and a restart_command on the framework worker replace the hardcoded php artisan queue:work, so a non-Laravel framework such as CodeIgniter (php spark queue:work) declares its own flag syntax and the MCP queue_start/queue_stop path routes through the same shared lifecycle.
  • Framework detection resolves offline from the cached store index (#774). The store index is written to a local cache file whenever it is fetched, and the watcher refreshes it at startup and every six hours. Detection now layers an installed store definition, then the cached catalogue, then the built-in adapter, so any framework in the catalogue resolves by name and version without a network round trip rather than only Laravel and Symfony.
  • Node management and the mkcert CA announcement are quieter on reinstall (#742, #732). The Node-management choice is remembered so it survives later installs, and install skips the gold "Installing mkcert CA" line and its implied sudo step when the root CA is already in the system trust store, running mkcert -install as a silent no-op so nothing is lost.
  • Search the preset picker and service tool cards (#719, #764). The service preset picker filters live by name, description or image with an empty state, every installable preset is promoted into the global command palette under an Install service group, and service tools render as cards.

Changed

  • Distribution and CI point at the lerd-env org (#756). The project moved from geodro/lerd to the lerd-env organisation, and the release, base-image and Pages references follow.
  • Go dependencies updated, including the x/net DoS fix (#766).

Fixed

  • lerd no longer writes its config block into shared and committed AI files (#792), so mcp:inject stops polluting an AGENTS.md or guidelines file that belongs to the project.
  • The DNS sudoers rule is no longer rewritten on every install (#730), install prompts stop swallowing typed-ahead input (#759), and the macOS podman-mac-helper sudo prompt is skipped when the helper is already installed (#761).
  • Host-worker consent is keyed on the command that actually runs (#770), so the v1.27 host-execution gate remembers the approval for the resolved command rather than re-prompting.
  • MCP podman hot-path calls are bounded and a stalled machine self-heals (#715), and the web UI check-for-updates button gives live feedback instead of appearing to hang (#786).
  • macOS machine heal, DNS teardown, and CA trust check are hardened (#791).
  • A large MySQL or MariaDB restore no longer dies with "Lost connection during query". A dump with a single statement above the server's max_allowed_packet aborted the connection partway through, and the bundled MySQL config never raised it off the image default while MariaDB shipped no config at all, leaving it on the 16MB default that a moderately large row already exceeds. The bundled MySQL config now sets max_allowed_packet to 256M and the MariaDB preset gains the same, with the service Config tab documented for dumps that need more. lerd db:import, lerd db:restore, and a cross-version service migrate also pass --max-allowed-packet on every SQL-load path so the client is never a second bottleneck once the server ceiling is raised.
  • v1.27 review follow-ups (#763, #773, #799, #802). Correctness, macOS-compatibility and safety issues found reviewing the v1.27 changes.

Docs

  • A CLAUDE.md agent guide and contributor-agent skills are added to the repo (#722), and the docs rail icon points at the documentation rather than the landing page (#712).

[1.27.1] - 2026-07-04

Fixed

  • Every published port of a service is now movable, and dashboard links follow the port they are served on (#738). A multi-port service (Mailpit's SMTP 1025 plus web UI 8025, RustFS's S3 9000 plus console 9001, Selenium's WebDriver 4444 plus noVNC 7900) could only have its primary port moved, so its other ports were stuck on the defaults and the dashboard link and iframe kept pointing at the old port after any change. Services now carry a per-container-port override so every published mapping is movable from the CLI with lerd service port <svc> <port> --container <cport>, from MCP with container_port, and from the dashboard Ports modal, which lists one editable field per published port. The dashboard link follows whichever port its service is served on, and the port-ownership guard shifts the other ports off host-owned defaults the same way it already did for the primary.

[1.27.0] - 2026-07-01

This release makes service ports something you manage rather than fight, rebuilds the site doctor as a framework-agnostic engine that works for every project, and hardens lerd against the messy realities of unclean shutdowns and podman upgrades. A containerised database now coexists with a host-installed server of the same engine by auto-shifting its published port, published and extra ports are editable from the dashboard and the CLI, and every bundled preset can publish the ports it declares. The Laravel-only doctor becomes a universal baseline plus store-declared per-framework checks, available from the web UI, the TUI, a new lerd site:doctor command, and the MCP diag site_doctor action. lerd auth ssh brings a shared ssh-agent so composer can reach private git repositories, lerd cleanup reclaims the podman disk lerd's own rebuilds leave behind, and FPM auto-starts (or offers to install) instead of dead-ending. Underneath, lerd now self-heals rootless podman after a major upgrade, recovers ghost containers and a lost nginx port after suspend, and routes every podman call through a single seam. A security pass closes the host-execution boundary so an untrusted project can no longer run commands or host workers without consent.

Added

  • Published-port management, and coexistence with a host-installed database (#633, #688, #708). When a DB engine's default published port (3306, 5432) is already owned by a host-installed server, lerd publishes its container on the next free port at quadlet-write time, persists the choice, and rewrites the connection URL, so the collision is avoided even at boot autostart where nothing is around to step aside; detection is a port-bindability probe at quadlet-write time, so a host server that holds the default port (on loopback or all interfaces) shifts lerd's container to a free port. A new lerd service port <svc> [port] | --reset moves or resets a service's published port with a pre-flight and a host-proxy .env follow, published and extra ports are editable from a Ports cog on each service page through a single shared serviceops layer the CLI, dashboard and MCP all call, and every bundled preset (not just the default-stack ones) can now publish the extra ports it declares, with its host port autofilled in the modal.
  • The site doctor is framework-agnostic (#686, #695). The Laravel-only doctor is rebuilt as a universal baseline that runs for every site (env file present, env drift against the example, application key, composer and node install and lockfile state, composer and npm security audits, a missing or empty SQLite database, and the PHP version range) plus each framework's own checks declared declaratively in the lerd-frameworks store through four typed evaluators, so a framework gains coverage by editing the store rather than Go. It is available from the web button, the TUI tab, a new lerd site:doctor command with --json, and the MCP diag site_doctor action, with dependency and audit findings exposing an allowlisted Fix button.
  • lerd auth ssh for composer over SSH (#646, #645). A shared lerd-ssh-agent sidecar holds unlocked keys in memory so lerd composer can authenticate to private git repositories, including passphrase-protected keys, with the agent socket on a named volume so it never crosses the host-VM boundary on macOS; the PHP runtime images (FPM and FrankenPHP) gain git and openssh-client so on-disk keys work too.
  • lerd cleanup reclaims orphaned podman disk (#647). A lerd-aware command that only ever removes images provably lerd's own, reaping the PHP rebuilds, stranded base images and superseded service images that accumulate over time, with a safe default tier and an on-demand --deep tier, a --dry-run preview, and an automatic once-a-day sweep (toggle with lerd cleanup auto off).
  • FPM auto-starts, or offers to install the PHP version (#652). Running php, artisan, lerd shell, tinker or the php:bun/pest:browser commands with a stopped FPM container now starts it automatically and waits, offers to install a missing version on a real terminal (or lists installed versions to switch to), and returns a clear non-interactive error over MCP instead of a raw podman failure or a macOS-wrong systemctl hint.
  • Self-healing rootless podman after a major upgrade (#658). lerd fingerprints the host podman version and network backend, and on the next run after a major-version or backend change rebuilds the lerd bridge in the new format, runs podman system migrate, and clears the stale rootless-netns before bringing containers up; lerd doctor also checks for the netavark, aardvark-dns and pasta/slirp4netns network helpers a fresh host can lack.
  • nginx recovers after the host resumes from suspend (#669). The DNS watcher dials 443 on its slow poll and, after two consecutive down reads on a server it has already seen healthy, restarts lerd-nginx so it rebinds its host ports, so .test sites stop returning "Secure Connection Failed" after a long suspend without a manual lerd start.
  • Port and build preflights (#628, #648). lerd install now warns about a foreign listener already holding HTTP 80, HTTPS 443 or DNS 5300 before any setup work, and the doctor gains rootless podman build preflight checks.
  • Framework prune and a remove guard (#624). A framework prune command and an in-use guard on framework remove so a framework still backing a site can't be pulled out from under it.

Changed

  • All podman execution routes through a single seam (#631). internal/podman becomes the one component that executes the podman binary, funnelling roughly 145 call sites through common Cmd/Run/CmdContext helpers with consistent binary-path resolution (so macOS Homebrew and launchd PATH lookups always apply) and error formatting, with a guard against any file outside the package building a podman exec directly.
  • The service YAML is the single source of truth for custom services (#679). Install state for non-default services now resolves from the definition YAML rather than the quadlet file, so the preset picker, install guard and dependency checks agree with the services list, and a reconciliation pass on lerd start regenerates a missing unit from its YAML and reaps an orphan quadlet with no backing YAML.
  • The host-proxy gateway rebind is a single change-gated restart (#696), and a host-proxy dev server binds the reachable host gateway so the proxy container can reach it without publishing the raw dev server on the LAN (#617, #694).

Fixed

  • Security: consent is required before running project-supplied commands and host workers on the host (#697). An untrusted .lerd.yaml could run host commands with no consent through host: true workers, an embedded framework_def, and lerd run / the dashboard / the commands_run MCP tool; an imported framework_def is now stripped of its host workers and commands, and a project's own host custom workers and top-level commands are tagged project-origin and gated at the host-execution boundary, prompting once and remembering the approval per site. A poisoned store definition imported by an older build self-heals on the next run.
  • Ghost containers are recovered on start (#699). An unclean Podman Machine shutdown can leave a container's libpod entry stuck Created while its storage layer is gone, so podman run --replace fails and the unit never starts; lerd now detects the signature and purges the stuck containers inside the VM before retrying the start once (darwin-only, filtered to status=created so running services are untouched).
  • Idle-suspend polish. Suspended host-proxy sites serve an auto-refreshing waking page instead of a dead 502 proxy and restore cleanly once the dev server is back (#673), a host-proxy site is paused when its dev-server worker is stopped rather than 502ing (#670), suspended workers wake on web access on macOS (#681), the in-container worker is reaped on stop so idle-suspend actually frees CPU on macOS (#662), and the idle moon shows in real time on resume (#703).
  • macOS and LAN-share fixes. Postgres runs native on Apple Silicon via an imresamu/postgis rewrite (#676), lerd-ui is reachable over TCP on macOS so idle and dump status work (#661), LAN-shared sites that force HTTPS no longer hit ERR_SSL_PROTOCOL_ERROR (#653), and the LAN-share proxy carries application WebSockets while only diverting Vite's HMR socket (#657).
  • Watcher, services and diagnostics. Source-file watches are bounded so an asset dump can't exhaust file descriptors (#675), a failed service start surfaces the real reason (#655), the doctor recognises a launchd-managed lerd-dns (#650), pest:browser boots the Playwright server in the doctor so a boot failure stops false-greening (#682), MCP site link honours host-proxy sites (#641), idle-suspended workers whose marking was lost on reinstall are recovered (#634), and the applehv provider is pinned for podman machine init on podman 6 (#701).
  • v1.26 review follow-ups (#689, #668). Correctness and quality issues found reviewing the v1.26 changes.

Docs

  • The MCP reference is synced with the current tool surface (#706), and the lerd-nixos flake is documented (#710).

[1.26.2] - 2026-06-26

A follow-up to 1.26.1 that completes the prebuilt-base pull fix for every podman version.

Fixed

  • Prebuilt PHP base images are pulled on all podman versions (#638). 1.26.1 gated the base pull's --policy=always flag behind podman 5.0, but podman pull --policy actually landed later (it is absent on 5.4 and every 4.x), so podman 5.0 through 5.4 still rejected the flag and fell back to a full local build. The flag is dropped entirely, podman pull already defaults to the always policy so it was redundant, and the real pull error line is surfaced when a pull genuinely fails.

[1.26.1] - 2026-06-26

A patch release fixing a silent fallback that made most Linux hosts rebuild every PHP image from source, alongside cherry-picked fixes for macOS LAN exposure, setup feedback, and host-worker reporting.

Fixed

  • Prebuilt PHP base images are pulled on podman below 5.0 instead of silently building from source (#638). lerd pulled the base with podman pull --policy=always, but --policy only exists on podman 5.0+, so on Ubuntu 24.04 (4.9.3), 22.04 (3.4.4) and Debian 12 (4.3.1) the flag was rejected; the pull's stderr was discarded, so the failure was invisible and lerd quietly fell back to the full local build even when a matching prebuilt base was published. The flag is now gated behind a podman 5.0 check and the pull error surfaces in the fallback message.
  • lerd setup reports npm and bun build failures instead of killing the process (#622). A failed install returns through the feedback step rather than calling os.Exit, so the failure shows in context.
  • Host workers whose dependencies aren't installed surface a remedy instead of dropping silently (#616), and lerd update's service restarts route through the feedback layer, warning rather than erroring when a best-effort restart doesn't take.
  • macOS LAN exposure keeps published ports and no longer double-binds the DNS forwarder (#618, #619). Bind-all IPv6 publish lines are rewritten to 0.0.0.0 rather than dropped so lan.exposed containers keep their ports, and the LAN DNS forwarder is skipped where the host dnsmasq already binds the LAN address.
  • Docs: the host database override example uses host.containers.internal (#626).

[1.26.0] - 2026-06-24

This release brings lerd's one-line installer to macOS, rebuilds the dashboard's editors on Monaco with a real PHP language server behind Tinker, reshapes the terminal UI into a clickable tabbed dashboard, and gives the whole CLI a styled feedback layer. The same curl … | install.sh one-liner that installs on Linux now installs on macOS, and lerd update self-updates there too. Every in-browser editor moves from CodeMirror to Monaco, and Tinker's autocomplete, diagnostics, hover, quick fixes and formatting now come from phpantom_lsp analysing the real project rather than static lists, with each statement's executed SQL captured inline and results tagged to the line that produced them. lerd tui becomes a mouse-driven, tabbed Dashboard/Sites/Services interface that mirrors the web UI, and the CLI routes through a shared feedback palette with spinners, aligned summaries, responsive tables and a unified error style that all degrade to plain text when piped. Linking is smarter, an unlinked project is offered the init wizard rather than dead-ending and non-PHP projects are handled, DNS gains pinnable upstream servers and heals across host network changes, postgres-timescaledb joins the presets, and the website moves to its lerd.sh home with a rebuilt landing page. Alongside the features, a focused security pass closes name-injection, root-write and websocket-rebinding holes, and DNS healing, TLS cert swaps, podman startup and MCP output get hardened.

Added

  • macOS support in the one-line installer (#549). The cross-platform install.sh makes the same curl -fsSL https://lerd.sh/install.sh | bash one-liner work on macOS the way it already does on Linux, detecting darwin and arm64, pulling the darwin_<arch> release asset, and installing any missing prerequisites through Homebrew without sudo while lerd install handles Podman Machine, mkcert, the /etc/resolver entry and the launchd plists. The bash PATH line is written to .bash_profile on macOS, lerd update self-updates there by downloading the darwin archive and reloading the launchd agents (deferring to brew upgrade lerd for a Homebrew-Cellar binary), and the uninstall path boots out the launchd agents and detached lerd-* containers cleanly. Homebrew stays as an alternative with a brew trust note.
  • Monaco editors across the dashboard, with phpantom_lsp behind Tinker (#552). Every in-browser editor moves from CodeMirror to Monaco, the Tinker REPL, the per-site and global nginx overrides, php.ini and service tuning files, and the site .env editor, lazy-loaded so the always-on bundle shrinks, with lightweight Monarch grammars for the nginx, ini and dotenv formats. Tinker's autocomplete, diagnostics, hover and signature help now come from phpantom_lsp, a self-contained Rust PHP language server lerd manages on the host alongside fnm and mkcert, so completions resolve the project's real models, relationships, scopes and vendor classes over a new /api/lsp/php websocket; if the server can't be reached the editor still works and code still runs.
  • A more REPL-like Tinker, with quick fixes, inline SQL, and per-line results (#554). The language server's quick fixes surface as Monaco code actions, so a "Class not found" warning offers an import and accepting a completion carries its use statement along, formatting is wired to Shift+Alt+F, the context menu and paste, and each statement's output is tagged with the editor line that produced it. For Laravel runs a query listener inlines bindings and emits every executed query as its own full-width card just ahead of the result it triggered, guarded by class_exists so plain-PHP REPLs are unaffected.
  • Clickable, mouse-driven tabbed TUI (#574). lerd tui is reshaped around a top tab bar with Dashboard, Sites and Services screens, switchable by click or ctrl+left/right, with mouse support throughout, clicking a tab, row, or a dashboard item jumps to its tab with that item selected, and the wheel scrolls whichever pane it is over. The Dashboard is a six-card overview mirroring the web UI with in-context app, service and worker logs, and the interactive accent becomes the brand red across the TUI and the CLI feedback layer.
  • A styled CLI feedback layer (#568). A new internal/feedback package is the single source of the lerd colour palette (shared with the TUI) and a set of progress primitives, spinners that resolve to a check or cross in place, a live accumulating line, aligned key/value summaries, styled prompts and an amber warning glyph, all degrading to plain text when stdout is piped, redirected, or NO_COLOR is set so the MCP server and scripts keep parsing stable output. The layer is extended across install and start (#604), worktree and service-stop output (#608), and uninstall (#612), and list views render as responsive tables with a unified error style (#610).
  • Smarter linking for unlinked and non-PHP projects (#558, #562). Running a command in an unlinked project offers to link it through the init wizard instead of dead-ending, and lerd link routes through that same wizard and handles non-PHP projects.
  • Pin upstream DNS servers (#583). Users can pin the upstream DNS servers lerd-dns forwards to rather than relying solely on the detected system resolvers.
  • postgres-timescaledb preset (#572). TimescaleDB joins the bundled preset catalogue as a same-family postgres alternate, installable and selectable like any other preset.
  • Updated MariaDB image list (#551). The available MariaDB versions are refreshed, keeping the bare 11 tag so existing installs still resolve.

Changed

  • The website lives at lerd.sh (#559, #560, #576, #578), served from the custom domain with the install one-liner and documentation links pointed there, a rebuilt homepage as a custom landing page with a live demo (#564), and refreshed social-share image and SEO metadata (#587, #589).
  • The geodro to lerd-env org move is self-healing (#602). Distribution links are centralised and the org cutover repairs itself across existing installs, alongside capping the inbound websocket frame payload size.
  • lerd-dns stays running across lerd stop (#571), so name resolution for .test domains survives a stop, with lerd quit remaining the full teardown that also stops lerd-dns.

Fixed

  • Security pass over the post-1.25 changes (#613). Site and worktree-branch names that flow into site_init shell commands and CREATE DATABASE identifiers are slugged so a hostile project directory or branch name can no longer break out, the NetworkManager dispatcher's per-user lerd.conf rewrite runs through runuser as the owning user so a symlink can't trick root into truncating a file, and the dashboard websocket's same-origin fallback now also requires an IP literal or a reserved local TLD so a public domain rebound to loopback is rejected.
  • DNS heals a stale lan:expose mapping when the host LAN IP changes (#553), with a PF_ROUTE watcher reacting to host network changes on macOS.
  • Legacy framework versions are served from the lowest available definition (#582) instead of failing to resolve.
  • AI agent detection is forwarded into the container (#555) and guaranteed for MCP-originated commands so in-container tooling sees it.
  • Worktree databases are provisioned and realigned on link and env, and TLS certs are swapped atomically (#607, #608) so an nginx reload never sees a missing cert.
  • Podman machine start is retried once before failing the install (#606), and versioned DB alternates no longer leak the bare canonical service through a substring match (#605).
  • MCP worktree, diagnose, service env and check-updates results are visible to the host (#550) by routing them through content blocks.
  • The tray uses a dark icon on light desktop panels (#585).
  • Dependabot alerts cleared (#566) by bumping vite and dompurify.

[1.25.0] - 2026-06-17

This release adds activity-driven worker suspension, makes bun a first-class JS runtime, and opens up how a PHP site's image is built. Idle-suspend gracefully stops a site's suspendable workers (queue, scheduler, Horizon, Reverb, the Stripe listener, Vite) after a period of no activity and brings them back on the next request, CLI command, MCP call, or source-file save, with a global toggle, a configurable timeout, and per-site pinning. bun can now drive a site's JS tooling on the host and, opt-in, run inside the PHP-FPM container, switchable from the dashboard, the TUI, or lerd js:runtime. PHP sites gain two ways to shape their image, extra Alpine packages baked in with lerd php:pkg and a fully custom Containerfile, and the FrankenPHP runtime now reaches extension and debug-tooling parity with FPM, so redis, gd, the database drivers, Xdebug, the dump bridge and the Debug window all work under Octane, alongside in-container Pest browser testing on musl chromium and per-project Octane auto-reload. The dashboard grows a Laravel Doctor panel, a Resources widget that accounts for lerd's whole footprint, a Clear logs action, and APP_NAME on the site tiles, RabbitMQ and RedisInsight surface their dashboards same-origin inside lerd-ui, the MCP server gains a logs tool, and the dashboard ships in six more languages. Alongside the features this release hardens the localhost/HTTPS paths, runtime switching, FrankenPHP relinks, and lerd stop.

Added

  • Activity-driven worker suspension (idle-suspend) (#525, #540, #541, #542). lerd watches each site for activity (HTTP requests via the nginx access feed, CLI commands, MCP tool calls, and source-file saves) and, after a configurable idle timeout, gracefully suspends that site's suspendable workers, the queue, scheduler, Horizon, Reverb, the Stripe listener, and the host Vite dev server, bringing them back the moment the site is used again. The suspend engine runs inside lerd-watcher, worktrees keep their own per-branch timers, and Vite builds its assets once on suspend so the site keeps serving. It is off by default and managed with lerd idle on/off, lerd idle timeout <duration>, lerd idle status, and lerd idle pin/unpin <site> to exempt a site, mirrored by a dashboard toggle. Turning it off resumes every suspended worker, and the engine reconciles against reality across installs and restarts so nothing is left stranded.
  • bun as a first-class JS runtime (#506, #521, #522). bun can drive a site's JS install, dev, and build on the host (auto-selected for bun projects, or when Node is unmanaged and only bun is present), toggled from the site's Node dropdown, the TUI picker, or lerd js:runtime [bun|node|auto]. For lerd shell and in-container tooling, lerd php:bun install drops a musl bun into a persistent /root/.bun volume that survives image rebuilds and is shared across PHP versions, with lerd php:bun update, lerd php:bun version, and lerd php:bun remove. lerd never version-manages bun; the user installs it and bun upgrade self-updates.
  • Custom Alpine packages in the FPM image with lerd php:pkg (#508). lerd php:pkg add/remove/list <packages> [--php version] bakes extra Alpine packages (CLI tools, libraries) into the FPM image, persisted under php.packages in config so they survive php:rebuild and base-image updates, layered onto the shared image rather than the published base.
  • Serve a PHP site from a custom Containerfile (#511). A PHP project can ship its own Containerfile built FROM the lerd base, so a site that needs a bespoke image is served from it directly rather than the shared FPM container.
  • In-container Pest browser testing (#518). lerd pest:browser install sets up pestphp/pest-plugin-browser inside the shared FPM container by baking Alpine's musl chromium into the image, persisting the Playwright registry in a volume, and shimming Playwright's glibc browser to the system chromium, with lerd pest:browser doctor and lerd pest:browser remove. Chromium only, current PHP versions only.
  • FrankenPHP extension and debug-tooling parity with FPM (#527). lerd builds a derived FrankenPHP image carrying the same runtime extension set the FPM image ships (redis, gd, the pdo drivers, intl, imagick, mongodb, and the rest), so they are present from first boot, and the dump bridge, Debug window, and Xdebug toggle all apply to a FrankenPHP site; only the SPX profiler stays FPM-only.
  • Per-project Octane auto-reload (#518, #520). A FrankenPHP worker-mode site can run Octane under a file watcher that reloads workers on code changes, toggled with lerd octane:reload [on|off] or from the dashboard, kept through lerd install, the Octane equivalent of Horizon's auto-reload.
  • Xdebug pause into running workers, plus on-demand mode (#516). lerd xdebug on --on-demand sets start_with_request=trigger so nothing auto-connects, and lerd xdebug pause [site] [--list] [--pid] uses Xdebug's control socket (xdebugctl) to break the IDE debugger into a running queue/Horizon worker, scheduled task, or CLI script. PHP-FPM sites only.
  • MCP logs tool (#515). An eleventh grouped MCP tool reads every log source (app, FPM, nginx, workers, services) with grep, since/until, level, and lines filtering, so an assistant can tail and search logs without shelling out.
  • Embedded RabbitMQ and RedisInsight dashboards (#514). lerd-ui proxies the RabbitMQ and RedisInsight management UIs same-origin under /_svc/<service>/ rather than sending you to a new tab, so they open inline like the other service surfaces.
  • Laravel Doctor (#509, #528). The site detail view and the TUI gain an on-demand Doctor panel that runs the framework's own health checks, sourced from the framework definition, with one-click fixes; it is worktree-aware and only warns on env keys the project actually requires.
  • Dashboard Resources widget (#536). A Resources card reports total CPU and memory across lerd's whole footprint, the lerd-* containers plus lerd's own host-side processes (the UI, watcher, and tray daemons and any host worker such as Vite), with the memory share of host RAM and a ranked list of the heaviest contributors.
  • Clear logs and APP_NAME on the dashboard (#510, #507). The App Logs tab gains a Clear logs button that reports the total log size and reclaims the disk behind a confirmation, and the site overview tiles are titled by a custom Laravel APP_NAME when one is set.
  • Six more dashboard languages (#505). The dashboard now ships in fourteen languages, adding Simplified Chinese, Japanese, Romanian, Italian, Polish, and Vietnamese.
  • TUI 1.24 integrations (#529). The terminal UI picks up the release's work: a bun entry in the Node picker, a FrankenPHP site's PHP picker limited to runnable versions, idle-suspended workers shown as suspended, the Laravel APP_NAME in the site header, an on-demand Laravel Doctor tab, opening a service's dashboard in the browser with O, and the services pane's workers sub-grouped by owning site.

Changed

  • A FrankenPHP site's PHP version picker is limited to runnable versions (#523). The dashboard dropdown only lists the versions FrankenPHP publishes an image for, so it never offers one that would silently downgrade the site.

Fixed

  • FrankenPHP runs the site's pinned PHP image instead of downgrading (#523), so a site pinned to 8.5 runs that image rather than dropping back to 8.4, and a committed FrankenPHP runtime no longer silently upgrades a site's PHP on relink (#538).
  • Workers stay healthy when switching a site's PHP runtime (#524), and a stale FrankenPHP container or orphaned quadlet is reconciled when a re-linked site is no longer FrankenPHP (#534).
  • A paused runtime site's container no longer autostarts at boot (#526).
  • Consistent localhost and HTTPS handling (#533, #537). .localhost mode behaves the same across lerd init, lerd secure, and the installer, a committed HTTPS choice survives the wizard on a localhost box, and a secured project stays on http when managed DNS is disabled, with SetSecured gated behind managed DNS so the UI and MCP match the CLI.
  • lerd stop retries systemd jobs reported "canceled" (#532), so it no longer leaves containers running on Linux.
  • lerd's php shim stays ahead of a global php for node child processes (#539), and FrankenPHP worker quadlets translate to launchd correctly when Exec= is quoted on macOS (#519).
  • The Xdebug dashboard toggle applies to FrankenPHP and custom-FPM containers (#538).
  • Dependency advisories cleared (#535) by bumping esbuild, vite, and form-data.
  • Hardening from pre-release review and smoke testing (#543, #546, #547, #531), covering FrankenPHP downgrades, log filtering, the dashboard service proxy, and idle-suspend reconciliation.

[1.24.1] - 2026-06-11

A fix release for v1.24.0. On macOS a fresh lerd install left every container unable to start: nginx, all PHP-FPM and all service containers failed with statfs /Users/<user>/.local/share/lerd/...: no such file or directory. The Podman Machine was initialised with -v /Volumes:/Volumes to keep external drives mounted, but Podman's --volume is a stringArray whose default set is /Users,/private,/var/folders, and passing any -v replaces that whole set, so the VM mounted only /Volumes and never the host home that every bind mount is sourced from. Init now passes all four required mounts explicitly so the home mount survives. A machine already broken this way can't be repaired by editing its config, because Podman writes the guest mount units once at init via Ignition and never regenerates them on restart, so lerd now detects a machine missing the home mount and recreates it on the next lerd install or lerd start (a machine in this state has no working containers, so nothing is lost). Machines created before the regression kept Podman's defaults and are unaffected, which is why it only surfaced on fresh installs. Linux is unaffected.

Fixed

  • macOS containers fail to start on a fresh install because the host home mount was dropped from the Podman Machine (#513). The regression came in with #458, which added -v /Volumes:/Volumes at podman machine init to keep external drives reachable; because --volume is a stringArray, that single -v replaced Podman's default /Users,/private,/var/folders set, so the VM lost /Users and every bind mount sourced from ~/.local/share/lerd failed. Init now passes all four paths explicitly so fresh machines mount the host home from the start, and a machine already broken this way (only /Volumes) is detected and recreated on the next lerd start. Recreation is required because Podman's guest mount units are baked at init by Ignition and a config edit alone never makes the path appear inside the VM. /Volumes is still mounted, so external drives keep working.

[1.24.0] - 2026-06-09

This release reworks how AI assistants talk to lerd and broadens what the dashboard shows. The MCP server collapses its roughly eighty flat tools into ten resource groups selected by an action argument, which roughly halves the per-session manifest and keeps it static so it no longer changes shape mid-session, and lerd now registers with Codex CLI, Gemini CLI, GitHub Copilot and Google Antigravity alongside Claude Code, Cursor, JetBrains Junie and Windsurf, all reading one shared, single-source tool reference. The Sites tab gains an overview dashboard when no site is selected, mirroring the redesigned, category-grouped Services dashboard, sites can be grouped under one base domain with shared or separate databases, and the list can be sorted and drag-reordered. Node and other non-PHP projects can run as host-proxy sites where lerd supervises the dev server on the host and nginx proxies to it, databases can be moved between same-family services with db:move, and soketi, OpenSearch, RedisInsight and Beanstalkd join the preset catalogue. The Stripe webhook listener now works for any framework with a configurable path, Horizon can auto-reload in development, and frameworks can declare unconditional env.vars defaults. Alongside the features, this release hardens the systemd and quadlet generation against directive injection from a cloned repo, gates host-proxy dev commands behind explicit consent, and stops lerd stop from raising spurious worker heal notifications.

MCP integrations: the tool surface is a breaking change. The old flat tool names (sites, artisan, db_set, …) no longer exist; call the matching group with an action instead (for example exec with action: "artisan", or db with action: "set"). Re-run lerd mcp:enable-global (or lerd mcp:inject) so your assistant picks up the new manifest and reference.

Added

  • Grouped MCP tool surface and four more assistants (#497, #498). The MCP server exposes ten resource-grouped tools, site, service, db, env, runtime, worker, exec, framework, diag and worktree, each driven by an action, routed through one dispatch table that reuses every existing handler, which cut the tools/list payload from roughly thirty-two thousand bytes to sixteen and made the manifest static so framework-specific actions no longer toggle the tool set mid-session. Client registration is table-driven: Codex CLI (~/.codex/config.toml), Gemini CLI (~/.gemini/settings.json), GitHub Copilot (~/.config/Code/User/mcp.json, VS Code's servers key with type: stdio) and Google Antigravity (~/.gemini/config/mcp_config.json) are now supported next to Claude Code, Cursor, Junie and Windsurf, and every assistant renders the same single embedded tool reference so the guidance can't drift between them.
  • Sites overview dashboard (#492, restored in #501). When no site is selected the Sites tab shows an overview instead of a bare prompt: a running-versus-total count, a paused tally and a failing-workers indicator, with sites laid out as click-through tiles grouped by framework plus Other and Paused buckets, each showing the favicon or status dot, the domain with a TLS lock, a framework and PHP or Node subline, worktree and worker indicators, and an open-in-browser shortcut. The chrome shared with the Services dashboard is extracted into reusable header, section, site-icon and worker-indicator components so the row and tile views stay in sync.
  • Redesigned services dashboard with categorized discovery (#491). The Services tab is reorganised into categorized sections with preset cards and a suggestion banner so installable services are discoverable at a glance.
  • Site groups (#484). Related sites can be grouped so a main owns a base domain and the rest occupy its subdomains, with an optional shared database per secondary, managed from a grouping modal on the site header and the site MCP tool's group_* actions.
  • Sortable, drag-reorderable sites list (#486). The sites list can be sorted, and dragging a site switches to a manual order that is persisted.
  • Host-proxy sites for non-PHP dev servers (#477, #487, #494, #495). A project can declare a proxy command in .lerd.yaml and lerd supervises that dev server on the host while nginx reverse-proxies the .test domain to it, with an auto-assigned free port, git-worktree support, and explicit consent before lerd runs a command from a project file.
  • db:move between same-family services (#488). lerd db:move --from <a> --to <b> relocates one or more sites' databases between two installed services of the same family (for example postgres to postgres-18), dumping from the source, creating and restoring on the target and repointing each site's .env, leaving the source data intact.
  • soketi, OpenSearch, RedisInsight and Beanstalkd presets (#489). Four services join the bundled preset catalogue, installable like any other preset.
  • Framework-agnostic Stripe webhook listener (#490, #493). The Stripe listener works for any project, not just Laravel: the secret is read from STRIPE_SECRET, STRIPE_SECRET_KEY or STRIPE_API_KEY, and a config control sets the forward path in .lerd.yaml so a non-Laravel route works.
  • Horizon auto-reload for development (#471). Laravel Horizon can run under a file watcher that reloads workers on code changes, toggled per project from the dashboard.
  • Unconditional framework env.vars defaults (#483). Framework definitions can declare top-level env.vars that are applied as defaults on lerd env, seeded only when the key is absent so a value set directly in .env is preserved, with .env.lerd_override and detected-service values still taking precedence.

Changed

  • MCP tools are grouped, not flat (#497). The roughly eighty individual MCP tools are consolidated into the ten grouped tools above. This is a breaking change for anything that called the old names directly; see the note at the top of this release.

Fixed

  • Reject inputs that could inject systemd unit or quadlet directives (#495, #500). Worker unit generation refuses a newline or NUL in the command at the generation boundary, so the boot and restore path is covered and a cloned repo's custom_workers entry can't inject a second systemd directive, and custom-service validation runs again at the quadlet boundary after dynamic_env resolves so inline services and dynamic env values are checked too.
  • Host-proxy dev commands stay behind consent on every path (#500). The watcher's worktree setup and lerd unpause now honour host_proxy.disabled and only run the command approved at link time, so a command edited into or shipped by a cloned .lerd.yaml is never run unattended.
  • lerd stop no longer raises worker heal notifications (#500). A lifecycle marker records an intentional shutdown, so the health watcher suppresses heal notifications, the dashboard banner and the heal surfaces for workers that lerd itself stopped; the workers stay enabled and come back on the next start, and a stop-then-reboot autostart resumes detection.
  • db:move keeps an explicit db.service block in sync (#500), so later lerd db commands resolve the service the site was moved to rather than the old one, and clears the block for sqlite which has no container service to target.
  • Host-proxy worktree ports and teardown (#500). Worktree dev-server ports are reserved against collision, and unlinking sweeps per-worktree worker units by name so the watcher's stale-site prune tears them down even after the project directory is gone.
  • Debug window events are separated by worktree (#481), so a worktree's captures no longer mix into the parent site's view.
  • MariaDB readiness and Vite worker boot ordering (#479, #480). MariaDB is probed with mariadb-admin rather than mysqladmin, and host Vite workers are ordered after the FPM container at boot so wayfinder doesn't run before PHP is up.
  • Recover from podman overlay-storage corruption on macOS (#476). lerd start detects the overlay-storage error and heals it, resetting the Podman Machine with a sized memory allocation when needed. The heal is gated to darwin with a tightened matcher so it never fires on Linux.
  • The dashboard .env editor scrolls to the bottom (#485), instead of cutting off its last lines on longer files.
  • Key generation runs through the framework console (#482), instead of a hardcoded artisan call, so non-Laravel frameworks generate their app key correctly.
  • Untranslated UI strings (#502). The site grouping modal, the command palette's generated labels, and the command run modal and dropdown are translated across all seven non-English locales; the dashboards already shipped their own translations.

[1.23.1] - 2026-06-04

A fix release for v1.23.0. Installing Postgres from the dashboard failed on Apple Silicon with podman pull ... exit status 125, and left a broken service registered behind it. The quadlet already pinned --platform=linux/amd64 so the container ran under emulation, but the explicit pull step that 1.23.0 added to surface progress ran a bare podman pull with no platform, and since postgis/postgis (like mysql:5.7) publishes no arm64 manifest for any tag, podman couldn't choose an architecture from the manifest list and exited 125 while the run path was happy. Pull and run now key off one shared predicate so they always agree on platform, and the streaming install pulls the image before it writes any config or quadlet, so a failed pull no longer leaves a service registered with no working image. It also fixes the dashboard's admin-tool banner, which only ever appeared on the bare postgres or mysql service and never on a versioned install like postgres-17. Linux and Intel macs are unaffected.

Fixed

  • Postgres install on Apple Silicon, with no half-installed service on a failed pull (#473). A new PlatformPullArgs injects --platform=linux/amd64 for the amd64-only upstream tags (postgis/postgis, mysql:5.7) on darwin and is a no-op on linux, and every pull path now routes through it, the three pull functions in the podman package, the FPM base pull, and the two CLI start and warm-up pulls, so the platform the quadlet runs with and the platform the pull fetches are decided by the same imageLacksArm64 predicate and can't drift apart by service name. Separately, the streaming install now resolves the preset, pulls the image, and only then registers it (config plus quadlet), because quadlet presence is the install-state truth: a failed pull used to write the YAML and quadlet first, so the service showed as installed but never started.
  • The admin-tool banner shows on any database service, regardless of version or family member (#474). The dashboard offers pgadmin for Postgres and phpmyadmin for MySQL through a banner on the database service, but the suggestion was keyed on the exact service name, so a multi-version install like postgres-17 or a MariaDB never got one even though the store already had a detectServiceFamily helper that resolves them back to the right family. suggestionFor and suggestedPresetFor now route through that helper, the way adminServiceFor already did. The matching gap was on the install side: an admin tool hard-depends on the bare family service (pgadmin on postgres, phpmyadmin on mysql), so MissingPresetDependencies refused to install it on a versioned-only host. A family dependency is now satisfied by any installed member of that family, and by any installed member of a sibling family the tool co-discovers via discover_family, so a postgres-17 covers pgadmin's postgres dependency and a MariaDB covers phpmyadmin's mysql one, matching what they already auto-connect to. The generated servers.json, pgpass and PMA_HOSTS already enumerate every family member with matching credentials, so the admin tool can browse all of them immediately after install.

[1.23.0] - 2026-06-03

This release moves lerd's whole configuration surface into the dashboard. Every file a developer used to open in an editor, a project's .env, a per-version php.ini, a site's nginx override, and a service's runtime tuning, is now editable in the browser, each through the same snapshot, optional timestamped backup, atomic off-glob write, validate and auto-rollback pipeline so a save that breaks the config restores the previous bytes and tells you it did. The System pages are tabbed, PHP versions install and remove from the dashboard with a live build log, and every editor is also reachable from the CLI and from MCP. The dump bridge becomes a full Debug window: a first-party Zend extension captures SQL queries with their real bindings alongside mail, views, events, jobs, cache and outgoing HTTP, grouped per request with N+1 detection, and the same window lands in the dashboard, the TUI and an analyze_queries MCP tool. The terminal UI grows a system view, a dashboard mirroring the web home page, modal overlays, toasts and a command palette. And Windows users get a dedicated WSL2 guide with lerd wsl:setup.

Added

  • Editable .env files in the dashboard (#434). The site detail panel gains an Env tab with a dropdown across env variants (.env, .env.local, .env.testing, .env.example), a CodeMirror editor with dotenv highlighting and line numbers, an opt-in backup checkbox that timestamps the prior contents as <file>.bkp.<YYYYMMDD-HHMMSS>, and a revert that restores the most recent backup over the file, peeling backups newest-first on repeated reverts. Writes are atomic and preserve the on-disk file mode. The CodeMirror bootstrap Tinker already carried was extracted into a shared CodeEditor primitive that both editors now use, and the new /api/sites/{domain}/env PUT and backup subroutes inherit the same loopback gate that already covered raw .env content.
  • Per-version php.ini editing and tabbed System pages (#436, #443, #448). The System dashboard folds its one-row-per-PHP-version list into a single PHP row whose detail page is tabbed: a worktree-style strip selects the version with a default star and a running dot, and the body splits into Logs, Sites and Config, where Config edits the per-version 98-user.ini and restarts FPM on save. Nginx gains a matching Logs and Config pair, and DNS and Watcher render their log viewers directly. A browser-style plus button installs any supported PHP version not yet present, building the FPM image server-side while streaming the build log live into the modal so closing it doesn't cancel the build, with a notification when it finishes; removing a version goes through a confirmation that warns when sites still use it. The global http-level override and the php.ini both save through the shared snapshot, optional backup, atomic off-glob write, validate and rollback pipeline.
  • Nginx config editing from the address bar, per site and per worktree (#437, #440, #445). A sliders button at the end of a site's address bar opens a modal that edits the per-site custom.d/<domain>.conf override, and a global http-level editor on the System nginx page edits http.d/zz-lerd-user.conf for gzip, proxy buffers and the like. Worktrees get their own override, seeded once from the main branch then independent, and the editor scopes to whichever worktree tab is active. Saves run nginx -t inside the container before the new bytes land, roll back to the snapshot and surface the captured stderr when validation fails, and leave an innocent save alone when the failure points at a pre-existing broken neighbour vhost. The same read, edit and reset are reachable from lerd nginx show/edit/reset (with --branch to target a worktree) and a site_nginx MCP tool. Underneath all of this a new cfgedit service owns atomic staged writes, timestamped backups, snapshot and rollback and a process-wide save lock, and the per-site nginx, global nginx, php.ini and env editors all route through it, pulling a few hundred lines of duplicated logic out of the web server.
  • User-editable runtime tuning for every service family (#429, #438, #439, #441, #442, #453). Each service exposes a runtime tuning override that lerd seeds once with a commented template and never overwrites afterward, bind-mounted after the bundled preset config so any value set there wins, editable from lerd service config <name>, a Config tab on the service detail panel, and a service_config MCP tool. mysql and mariadb land first through their auto-included conf.d; redis and postgres load no config by default, so the family declares a container command that points the image at the file, and because include_dir is rejected via -c, postgres starts against a small lerd-managed config_file= wrapper that includes the cluster's own postgresql.conf and then the override directory. Custom services opt in with a tuning: block on their YAML carrying the mount target, the seeded template and an optional command, so lerd doesn't have to recognise their family. The web editor mirrors the env and nginx tabs with a save-and-backup modal, a restore diff and a Reset to bundled defaults, and a save that crashes the service auto-rolls back to the prior bytes and reports "save reverted, prior config restored" rather than a bare "service did not become ready" that reads as if the service is still broken.
  • The dump bridge becomes a full Debug window (#462, #464, #468). A first-party Zend extension, lerd_devtools, captures SQL queries at the PDO layer with their real bindings, whether the app passes them to execute() or binds them one at a time, and framework-agnostic seams capture outgoing mail, rendered views, dispatched events, queued jobs and outgoing HTTP; Laravel keeps the richer data from its in-app adapter and the agnostic seams stand down while it's active so nothing is captured twice. Every kind ships over the same socket and ring as dumps and renders as sibling tabs in one Debug view, grouped per request with N+1 detection and slow-query flags, and the dump bridge and collector share one enable sentinel so a single toggle arms the whole window, renamed the Debug bridge throughout the dashboard, tray, TUI, CLI and docs. The extension now compiles in the prebuilt base image instead of on each user's machine at install time, with its source hashed into the Containerfile so an extension-only change still drifts the image and existing users rebuild. An analyze_queries MCP tool returns the N+1 and slow-query report with the originating file and line, dumps_recent gains a kind filter, and the TUI's Debug view gets the same eight lenses (Dumps, Queries, Jobs, Views, Mail, Cache, Events, HTTP) switched with [ and ], with per-lens buffered counts and a worker-capture toggle on w.
  • A system view, dashboard and modal layer for the TUI (#417). The terminal UI gains a System pane (Y) covering DNS, nginx, watcher, notifications, the Debug bridge, per-version PHP with Xdebug, Node and worker mode with reversible toggles that route through the lerd CLI so the TUI shares the same code paths as a manual invocation, and a Dashboard pane (F) mirroring the web home page with a hero status line, an overview roll-up, a system health block and a resources block backed by a new internal/stats package shared with lerd-ui through a singleflighted podman-stats cache. The command palette, the PHP/Node version picker, the help reference and a new destructive-action confirm render as centered modal overlays; toasts replace the disappearing status bar with per-kind TTLs and a dismiss key; the dumps view picks up search and context chips; and the services pane groups into Core, Custom and Workers with the header pill naming the failing units instead of just counting them.
  • Personal .env.lerd_override for per-project overrides (#469). A personal, never-committed .env.lerd_override at the project root lets each developer override the values lerd writes into a project's .env without hand-editing it after every lerd env. Every KEY=VALUE is layered on last so it wins over lerd's defaults and computed values like DB_DATABASE and APP_URL, written verbatim so quotes are preserved, and the one reserved key, LERD_EXTERNAL_SERVICES with a comma-separated list, marks services lerd should write connection vars for but not start or provision, which is how a project keeps using a developer's own local postgres while lerd manages everything else. A new lerd env:override command scaffolds the file from a commented template and makes sure it's gitignored, an env_override MCP tool exposes the same flow, and lerd env:check skips the file since it's a partial overlay. A build guard now fails if any registered MCP tool is ever missing from the injected skill and guidelines again, after nine tools were found undocumented.
  • Windows (WSL2) getting-started guide and lerd wsl:setup (#432). A dedicated page covers installing lerd on Windows through WSL2: enabling systemd in /etc/wsl.conf, mirrored networking in .wslconfig, the events_logger = "journald" tweak in containers.conf without which every dashboard log pane shows a podman log-driver mismatch error, keeping projects in $HOME so bind mounts don't route through 9P, masking lerd-tray since WSL2 has no tray host, and exporting the mkcert root CA to the Windows trust store so https://*.test is trusted by Chrome and Edge. Both .test and .localhost DNS modes are presented as viable, validated by a community user on the issue thread, and WSL flag support ships as beta.
  • Check for updates on the service header (#418). A Check for updates action on each service header forces a fresh registry lookup instead of waiting for the cached availability snapshot to age out, dropping both the 30-second in-memory and 6-hour on-disk registry-tag caches before recomputing and surfacing the outcome (already up to date, update available with the new tag, or check failed) in a toast. POST /api/services/{name}/updates is now the trigger; GET still serves the cached value so snapshot rebuilds stay cheap.

Fixed

  • composer require survives cold container boots (#450). composer require/install died with "exceeded the timeout of 300 seconds" while running artisan package:discover as a post-autoload-dump script, because every artisan command boots the full application and on a cold container filesystem a heavy provider tree, reliably reproduced once Reverb is installed, legitimately runs past composer's stock 300s. lerd now injects a 1800s COMPOSER_PROCESS_TIMEOUT into every path that runs composer inside the FPM container: the lerd composer CLI, the composer MCP tool, and the setup-time auto composer install. Closes #449.
  • Long worktree branch names no longer take nginx down globally (#456). A worktree with a long branch name emits a server_name that overflows nginx's default server_names_hash_bucket_size: 64, so nginx fails to start and every site goes down, including the dashboard. The http {} block now carries server_names_hash_max_size 1024 and server_names_hash_bucket_size 128 at the source so every generated config has it. Fixes #455.
  • lerd start stops flagging its own port owners as conflicts (#461). On macOS the preflight printed false port-conflict warnings on a healthy machine, for :5300 on every start and for service ports after any stop/start cycle, even though .test kept resolving and the stack started fine. lerd's own dns and gvproxy port owners are no longer reported as conflicts, so the warning stops crying wolf and training users to ignore it.
  • A site's custom nginx override follows a domain rename (#444). Renaming a site's primary domain through the UI silently dropped its custom nginx config: lerd deleted the old vhost and generated a new one including custom.d/<newdomain>.conf, a file that never existed, so the user's directives vanished from the live site while the old file sat orphaned on disk. RegenerateSiteVhost now moves the override and its timestamped backups to the new name whenever the primary changes, never overwriting backups, and lerd migrate-tld does the same for every site whose primary domain shifts when the TLD changes.
  • .env APP_URL syncs when the primary domain changes in the UI (#435). The web UI's domain handlers regenerated the vhost, reissued the cert and reloaded nginx but never touched the project .env, so renaming the primary domain left APP_URL and the reverb keys pointing at the old host; the CLI already had the guard but the UI is the only surface that can rename a domain in place. A new siteops.SyncEnvIfPrimaryChanged seam both surfaces call rewrites the parent .env and each worktree's APP_URL to the new host, collapsing five copies of the same conditional.
  • PHP-only controls hidden on static sites (#446). A static site is just a public dir of HTML served by nginx with no PHP, but the detail panel showed every PHP-only surface because the UI gated on php_version, which lerd populates from the global default even for non-PHP projects. A proper uses_php signal, a detected framework, a composer.json, a top-level .php file or a public/index.php, and never a custom container, now lives in php.SiteUsesPHP and gates the version dropdown, the Tinker and Dumps tabs and the FPM logs; RestartSite refuses a static site rather than bouncing the shared FPM container every PHP site on that version depends on, and Xdebug moves to a bug-icon toggle in the address bar.
  • Installed-detection unified on quadlet presence (#433). The MCP service_preset_list, the web UI presets endpoint and lerd service preset answered "is this installed?" by probing for the YAML in ~/.config/lerd/services/, while the rest of the codebase probes the quadlet, so an orphan lerd-mysql.container with no mysql.yaml read as a running MySQL the MCP layer insisted was not installed. A canonical serviceops.ServiceInstalled probes the quadlet because that's what podman actually runs, and every presence check across MCP, the web UI, the CLI, the TUI and the lerd check validators routes through it, with the lerd check failure path now suggesting the concrete remediation command.
  • macOS mounts system directories to the Podman machine on first init (#458). lerd now mounts the system directories during the initial Podman machine initialization on macOS, so the path no longer depends on a later run to wire them up. Closes #452.
  • Pre-1.23 review follow-ups (#459, #467). A sweep of everything merged since 1.22.1 fixed: the config editor's atomic writer staging a .tmp that matched the nginx include glob, now a dot-prefixed temp through os.CreateTemp; a data race clearing the TUI dump buffer from a tea.Cmd goroutine, now routed through a message applied on the update loop; a first-time global nginx save on an install predating the http.d mount reporting success while the container crash-looped, now it waits for the container and validates with nginx -t, rolling back on failure; an upgrade writing a custom service's tuning mount without restarting the running container; .env and service-tuning restore ignoring the previewed backup and always restoring the newest; a tuning auto-rollback showing the raw error with no sign the old config was back; a notifications toggle for the possible N+1 query warning that previously always fired; the worker-queries switch now hiding already-buffered worker queries rather than only stopping future capture; and a stray NUL byte in notify.ts that had been making git treat the file as binary.

Security

  • Cross-origin requests to the dashboard are blocked (#447). Any page open in the developer's browser could fire a CORS-simple text/plain POST to http://127.0.0.1:7073 and reach state-changing endpoints, including the per-site tinker route that runs arbitrary PHP in an FPM container with the project .env and host .ssh mounted, since loopback bypassed every existing check and a simple POST carries no preflight and no custom header. State-changing methods now have to prove they came from lerd's own dashboard: a request passes when the browser labels it same-origin, same-site or none, when it's cross-site but carries one of lerd's own origins, or, for clients that send no Sec-Fetch headers at all, when it carries a non-empty X-Lerd-CSRF header that a forged cross-origin request can't set without a preflight lerd only answers for its own origins. The dashboard injects the header automatically on unsafe requests; the unix socket, the remote-setup bootstrap, the mailpit webhook, the notify bridge and the non-destructive per-site unpause keep their own source gates and are exempt.
  • vitest bumped to v4 (#466). Dependabot flagged vitest 3.2.4 as critical (GHSA-5xrq-8626-4rwp, arbitrary file read and execute when the Vitest UI server is listening), a dev-only dependency lerd never runs the UI server for, so real exposure was minimal; the fix lands in 4.1.0 so this moves to ^4.1.8, and regenerating the lockfile pulled the semver-safe ws patch that cleared the remaining moderate advisory, leaving npm audit clean.

[1.22.1] - 2026-05-27

A fix release polishing v1.22.0. The PHP-FPM image rebuild path is now self-healing after a brew upgrade, with an OCI label on every built image so a stale cache file can't strand users on the old image. Podman older than 4.5 fails install with an actionable error instead of a raw cobra panic, and the requirements page lists the affected distros (Ubuntu 22.04, Zorin 17, Debian 11 and 12) alongside the per-distro workaround. The nginx catch-all _default.conf survives hand edits across lerd start thanks to a hash sidecar lerd stamps when it writes the file, and the rewrite itself is now atomic and mode-preserving. lerd dns:check no longer false-alarms on hosts running a sibling dnsmasq, lerd lan expose pre-flights the forwarder port, and lerd service reinstall is transactional and routes through the preset name so a failed reinstall no longer wipes the old install before failing. macOS gains a gvproxy port-forward heal after a Podman Machine restart, the tray Quit menu item actually stops services and exits, and the tray surfaces a stderr warning when the lerd binary can't be located under launchd. The TUI's update-check banner stops false-firing on stable installs.

Fixed

  • PHP-FPM images rebuild when the Containerfile changes (#415). lerd install (and the install pass inside lerd update) used to advance ~/.local/share/lerd/php-image-hash without doing the build, because ensureFPMQuadlet no-ops BuildFPMImage when the image already exists but unconditionally stamped the current Containerfile hash. After a brew upgrade that introduced template changes (SPX in v1.22.0), the on-disk hash matched the new template even though the image was still the old one, so NeedsFPMRebuild reported "up to date" and the rebuild was silently skipped. StoreFPMHash now runs inside buildFPMImage only after a real build completes, so no path advances the hash without actually rebuilding. lerd install also re-checks NeedsFPMRebuild itself and re-execs php:rebuild when a drift is detected, so brew upgrade && lerd install is self-healing without needing lerd update to run the same check afterwards. The rebuild is gated on autostart enabled so it does not re-start units that the user explicitly stopped, and the parallel per-version builds serialise their hash writes through a mutex so they can't truncate each other.
  • OCI labels on PHP-FPM images detect a poisoned hash cache (#427). The previous fix corrected the path that advanced the cache, but couldn't auto-heal users already in the poisoned state: their cache file already matched the new Containerfile, so NeedsFPMRebuild returned false and the rebuild was still skipped. Every PHP-FPM image lerd builds now carries a dev.lerd.fpm.containerfile-hash OCI label recording the hash it was actually built from. NeedsFPMRebuild checks both signals: cache file versus embed (the fast path) and each installed image's label versus embed (the recovery path). Empty labels (images built by pre-fix lerd binaries) are treated as mismatches, so the first lerd install after the upgrade triggers a one-time rebuild that stamps the labels. From then on the labels are authoritative and the cache file is just a fast-path optimisation.
  • PHP-FPM rebuild check no longer scans orphaned legacy images (#430). The label scan introduced above used to iterate every lerd-php*-fpm:local image on disk, including pre-v1.22.0 images for PHP versions the user had since removed. Those orphans carry no containerfile-hash label, so the comparison always failed and lerd install re-fired the rebuild banner on every run, even after a successful rebuild of the active versions. NeedsFPMRebuild now reads the active version list from phpDet.ListInstalled and only checks labels on those versions' images. A centralised FPMImageName helper keeps the rebuild check and the build paths agreeing on the naming convention, and a ContainerfileHash read error now forces a rebuild instead of being silently treated as up-to-date.
  • _default.conf survives hand edits across lerd start (#425). EnsureDefaultVhost rewrote the catch-all conf on every start, clobbering any local patches. Users tuning the catch-all (e.g. swapping ssl_reject_handshake on for off on a staging machine) had to re-apply the change after every restart. lerd now stamps a sentinel _default.conf.lerd-managed-hash alongside the conf with the sha256 of what it last wrote. On subsequent runs the on-disk content is hashed and compared: a matching hash means lerd updates the file in step with template changes, a mismatch means lerd preserves your edit and logs a one-line INFO that it skipped the rewrite. A missing sentinel with matching content reclaims management (recovers from a transient FS failure on the sentinel write), a missing sentinel with diverging content is treated as user-managed with a message that explicitly calls out the upgrade case and tells you the exact rm to restore lerd's default. The sentinel filename suffix stays outside nginx's *.conf include glob so it never gets loaded as config.
  • _default.conf is written atomically and preserves file mode (#430). EnsureDefaultVhost used two back-to-back os.WriteFile calls for the conf and its sentinel, so a crash between them left lerd-managed content with no sentinel, indistinguishable from a user-edited file on the next run. An in-place truncate also briefly exposed half-written nginx config to a concurrent reload. A new writeFileAtomic helper writes to a sibling .tmp, chmods to the desired mode to defeat umask, then renames atomically into place. Crashes mid-write leave the destination at its previous state, never partial. When the destination already exists, the helper preserves its mode so an out-of-band chmod survives the rewrite.
  • Clear error when podman is older than 4.5 (#421). podman network create --dns was added in podman 4.5 (April 2023). Older releases used to fail lerd install with a raw cobra "Error: unknown flag: --dns" and no hint about what to do next. The install path now translates that specific stderr signature into a message that names the minimum supported version and points at the requirements docs. The substring match is anchored to bare --dns followed by a newline so flag names sharing the prefix don't trip the rewrite, and the wrap uses fmt.Errorf %w so callers (including the ErrNetworkNeedsMigration sentinel on the migration path) still see the original error via errors.Is. Refs #420.
  • lerd dns:check recognises a legacy host-side resolver (#426). The diagnostic reported "lerd-dns container not running" as a hard FAIL whenever the container was absent, even if a host-side resolver (Homebrew/launchd dnsmasq, system package) owned :5300 and was answering the TLD correctly. The top-line summary read "DNS is NOT working" when DNS was in fact working perfectly, just not through lerd. Rung 1 now distinguishes three sub-cases when the container isn't running: port :5300 in use and answering .test with 127.0.0.1 reads as WARN "host-side resolver is handling DNS; lerd isn't managing it", port :5300 in use and answering with a different IP also reads as WARN (a LAN-IP setup for cross-device testing, with the actual answer surfaced in Detail), and port :5300 free or held by a non-DNS process reads as FAIL with the probe error in Detail so NXDOMAIN, timeout, and refused stay distinguishable. Hints now emit on WARN as well as FAIL so users see the remediation suggestion in both cases. Reported in #414.
  • lerd lan expose pre-flights the forwarder port (#424). lerd lan expose used to install lerd-dns-forwarder (listening on lanIP:5300) without checking whether the address was already taken. On hosts running their own dnsmasq via Homebrew/launchd or a sibling tool, the new unit would write fine and then both daemons would race for the same address on every boot. Pre-flight now tries to bind UDP and TCP on lanIP:5300 and refuses the install if either fails, with an lsof line (macOS) or ss -tulpn hint (Linux) identifying the holder. The check is skipped when lerd's own forwarder is already active (re-runs of lerd lan expose replace themselves cleanly), and also when launchd or systemd report the unit as deactivating, so a quick lerd lan unexpose && lerd lan expose doesn't catch our own forwarder mid-stop and report it as the conflict. Reported in #414.
  • lerd service reinstall is transactional and routes via the preset name (#423). lerd service reinstall <name> had two failure modes that left a service in a half-broken state. First, the custom-service path was passing the SERVICE name (e.g. "mariadb-10-11") to InstallPresetStreaming, which expects the PRESET name (e.g. "mariadb"), so the call failed with "unknown preset mariadb-10-11" after RemoveService had already deleted the YAML and container, leaving the user with neither the old install nor a fresh one. CustomService.Preset is now consulted as the breadcrumb to the source preset and routed through spec.presetName. Second, configuration errors (missing preset, bad version, unsatisfied deps) and network errors (image registry unreachable) fired AFTER RemoveService had run. A pre-flight pass now replays the install path's checks without side effects (preset load, version resolve, missing dependencies, plus a "version-on-single-version-preset" sanity check) and runs the image pull before RemoveService, so on-disk state stays intact when validation or the pull fails. Default-preset and custom-service paths share the same validation so both fail closed under the same conditions. A separate fix suppresses the family-consumer regen pass during the reinstall's remove step (it was rendering a partial plist that excluded the service being reinstalled, which on macOS could race with the post-install regen and leave consumers pointing at the wrong env), with explicit regen restored on the install-failure path so a failed reinstall doesn't strand consumers pointing at a deleted container.
  • gvproxy port forwards heal after a Podman Machine restart on macOS (#422). After a Podman Machine restart (machine stop/start, a Mac reboot, or a crash), lerd-* containers come back up inside the VM via --restart=always, but gvproxy on the host loses its port-forward table and only learns about forwards when podman run -p is invoked from the host CLI. The result was sites that looked "running" but refused on :80/:443, with no lerd start, service restart nginx, or launchd kick fixing it on its own. lerd now records the machine's LastUp at every successful start and install, and compares it on the next run. When the value moved (external restart) it force-removes running lerd-* containers so the StartUnit pass that follows invokes podman run -p fresh from the host and gvproxy re-registers the forwards. The LastUp is sampled before ensurePodmanMachineRunning so internal stop+starts (rootful or memory adjustment) don't trigger a phantom heal. The same pattern runs from lerd install so users reaching container-start via lerd update get healed too. Linux is unchanged: every function is a no-op since podman runs natively without a VM or gvproxy.
  • macOS tray Quit actually exits and stops services (#419). Two bugs combined to make the tray's Quit menu item appear to do nothing. The launchd plist for lerd-tray (and lerd-ui, lerd-watcher) was generated with a bare KeepAlive=true whenever the systemd unit had Restart=on-failure, so launchd respawned the job on any exit including a clean os.Exit(0), which is not what Restart=on-failure means. Restart=on-failure now maps to KeepAlive: SuccessfulExit=false instead, and Restart=always still emits the bare KeepAlive=true. Separately, handleQuit ran exec.Command("lerd", "quit") but launchd's job environment has no PATH covering /opt/homebrew/bin (where the Homebrew-installed lerd binary lives) so the call silently failed and none of the containers Quit was supposed to stop got stopped. The lerd binary path is now resolved once per call via exec.LookPath plus a known-prefix fallback, and every tray-side lerd … invocation routes through a small wrapper. Re-resolving on every call (instead of caching) keeps the tray correct across mid-session reinstalls.
  • Tray warns when the lerd binary can't be located (#430). Under launchd the tray runs with an empty PATH, so lerdBin walks a fixed list of candidate install dirs before falling back to the bare string "lerd". That fallback used to be silent: menu clicks would fail with an exec error that surfaced nowhere obvious, leaving the user staring at an unresponsive tray. The function now prints a deduped warning to stderr the first time it falls through to the bare-name fallback, so launchd's error log surfaces the missing binary instead. A sync.Once keeps the warning from spamming on every menu click.
  • TUI update-check banner stops false-firing on stable installs (#416). tui.Run was receiving version.String() (e.g. "1.22.0 (commit ABCDEF, built 2026-05-25T15:43:56Z)") and threading it through to CachedUpdateCheck. splitPrerelease finds the first - in the string and treats everything after it as a pre-release suffix, so the comparison degraded to "latest (no pre) > current (has pre)" and the banner fired even when the user was on the latest stable. The TUI now passes version.Version instead, matching every other caller and keeping the header line short.

[1.22.0] — 2026-05-25

The headline is a profiler: SPX is bundled into every PHP-FPM image behind a single global toggle, and while it is on every request to every shared-FPM site is recorded as a flame graph, viewable in a same-origin Profiler overlay in the dashboard or driven from lerd profile and two MCP tools. The dashboard's System health card turns interactive, with the profiler, the dump bridge and notifications all becoming live antenna-style toggles backed by a reusable PulseToggle component. lerd init now seeds its setup wizard from an existing herd.yml, .ddev/config.yaml or .lando.yml so teams migrating from Herd, DDEV or Lando start from real values instead of a blank form. A new postgres-pgvector preset lands as a first-class service for vector-embedding workloads, alongside a reworked database picker that builds its option list dynamically so every installed family alternate shows up by name. And DNS gains a real handle on VPN clients: health goes three-way so a VPN rewriting the system resolver reads as a yellow degraded state rather than a red outage, lerd-watcher and lerd-ui both subscribe to kernel rtnetlink events so a connect or disconnect is reflected within a second in the dashboard pill and the new DNS entries in the Recent Activity feed, and container DNS is re-synced automatically against the new host resolvers so PHP can reach VPN-internal endpoints without a manual restart.

Added

  • DNS state changes appear in the dashboard's Recent Activity feed (#410). Transitions surface as DNS degraded (amber, with a "VPN active" suffix when a tunnel is up), DNS down (red), and DNS recovered (green), so users get a historical record of when DNS state shifted, not only a live pill colour. Diff logic is a pure helper that emits exactly one event per status change and stays silent on the vpn-only flag flipping under a steady degraded state. Labels are translated across all eight existing locales.
  • postgres-pgvector preset and a dynamic database picker (#392). The postgres-pgvector preset (v18 canonical, plus v17 and v16 alternates, image pgvector/pgvector:pg<N>, host ports 5518/5517/5516) lands as a first-class lerd service for vector-embedding workloads instead of a hand-rolled custom yaml, and participates in pgadmin auto-discovery via discover_family:postgres. Wiring it up fixed three rough edges in the postgres-family code paths. Family resolution is now a single helper: config.FamilyOf and config.FamilyOfName prefer the explicit Family field over name-pattern inference, which previously only matched the <family>-<digit> shape and so resolved postgres-pgvector to no family at all, with the result that env.go decided it was not a database and skipped the DB_DATABASE rewrite and database creation. config.IsDBServiceName is the same idea for the boolean used by db_set validation. The env wizard and the db_set MCP tool now build their option list dynamically from the installed mysql and postgres family members plus sqlite, so any alternate (mariadb, postgres-pgvector, future ones) shows up by name instead of a hardcoded three-way choice. The multi-version preset picker gains canonical versions, marked (default) in the label and preselected, via a new config.PresetVersionServiceName helper that centralises canonical-versus-suffixed name resolution across the CLI table, the web UI, and the MCP list. Refs #378.
  • lerd init seeds the setup wizard from Herd, DDEV and Lando config (#393). Teams migrating to lerd from Laravel Herd, DDEV or Lando already have a project config file that lerd previously ignored. When lerd init runs in a project that still carries a herd.yml, a .ddev/config.yaml or a .lando.yml, with both filename spellings recognised for each tool, it detects the file and asks whether to use it for the wizard defaults. Accepting it translates the PHP version, Node version, HTTPS preference, document root, domains and the database or service list into the wizard's defaults, which are then reviewed and confirmed exactly like any other run; declining leaves the wizard on plain auto-detection. The prompt only fires when no .lerd.yaml exists yet, so it never touches an existing lerd configuration. The translation is deliberately partial and everything it drops or changes is printed as a note before the wizard opens: pinned service versions and ports are dropped because lerd resolves those per machine, MariaDB folds into MySQL with a note pointing at lerd service preset mariadb for anyone who needs it specifically, the framework is never translated since lerd auto-detects it more reliably, and seeded domains and document root are surfaced in the notes because the wizard has no prompt for them.
  • SPX profiler with a global toggle and dashboard view (#395). lerd gains a profiler for the question dumps and logs cannot answer, where a request actually spends its time. SPX, a low-overhead PHP profiler, is bundled into every PHP-FPM image behind a single global on/off switch; while it is on, every HTTP request to every shared PHP-FPM site is recorded as a flame graph. Turning it on or off flips a config flag, regenerates the FPM site vhosts so nginx injects an SPX cookie into each request, and reloads nginx, with no FPM restart and nothing to add to projects. The SPX cookie is suppressed whenever an X-Forwarded-Host header is present, so the profiler stays unreachable through tunnels and LAN shares. The SPX report UI is reverse-proxied same-origin under /_spx/ by lerd-ui and appears as a Profiler entry in the dashboard icon rail, opening in the same overlay the service dashboards use. From the terminal there is lerd profile on/off/status/open, lerd profile run to profile a one-off artisan or CLI command, and the profiler_toggle and profiler_status MCP tools. Profiling covers shared PHP-FPM sites only, since FrankenPHP and custom-container sites run images without the SPX extension.
  • Profiler, dump bridge and notification toggles in the dashboard System health card (#396). The System health card gains interactive toggles: the SPX profiler gets one, the dump bridge row becomes a real toggle instead of a read-only dot, and the notifications row is a bell toggle that flips the master switch and prompts for browser permission on first use, dimming when the browser has blocked notifications. Each is an antenna-style emerald icon with a slow pulsing dot while the feature is live, and all six System health indicators now sit in one centered column so the card reads as a single set. The left rail's profiler icon carries a green dot whenever profiling is on. The Profiler overlay gains a Clear data button, with clearing captured reports also available as lerd profile clear, the profiler_clear MCP tool, and the /api/profiler/clear endpoint, all backed by a shared profiler.ClearData. Profiler state is broadcast over the websocket as a new profiler_status kind so the indicator and the rail dot stay live when the profiler is toggled from the CLI, MCP, or another browser tab, matching how the dump bridge already behaved. The shared antenna-style button was extracted into a reusable PulseToggle component.

Fixed

  • VPN connect and disconnect react within a second via rtnetlink (#410). The beta's container DNS re-sync and the dashboard pill were both poll-driven on a 30 second interval, so after a VPN toggle the pill could sit on a stale red colour and PHP inside containers could keep resolving against the wrong forwarders for up to a minute. lerd-watcher and lerd-ui now both subscribe to rtnetlink RTMGRP_LINK / RTMGRP_IPV4_IFADDR / RTMGRP_IPV6_IFADDR multicast events with a 750ms debounce, so a kernel-reported link or address change kicks an immediate re-probe and re-sync. The 30 second poll stays as a safety net so a missed kernel event cannot strand the system. VPN detection is also broadened: ProtonVPN's proton0 and protonwire0, Mullvad, and any branded client whose interface carries the POINTOPOINT flag now register as a VPN, so the degraded-state downgrade fires for them too. The same fallback engages for cellular tethering, where reading a broken system resolver as degraded rather than down is the desired outcome. On hosts where rtnetlink is unavailable (seccomp-filtered services, certain hardened container namespaces), LinkChanges emits a one-shot warning to journalctl and the existing poll takes over. macOS keeps the build-tagged no-op path: both watchers fall back to the 30 second poll, unchanged from before, since macOS containers resolve through the podman machine VM.
  • DNS no longer false-alarms under a VPN, and container DNS re-syncs automatically (#397). Connecting a corporate VPN such as Cisco AnyConnect made the dashboard show a red DNS outage even though lerd-dns was healthy and answering, because the VPN client rewrites the system resolver so the TLD stops routing to lerd-dns. DNS health is now three-way: when the system resolver path fails, lerd queries dnsmasq directly on 127.0.0.1:5300, and a direct answer reads as a yellow degraded state rather than a red down. The degraded state renders consistently across the dashboard pill, the tray icon, the TUI header and lerd status, lerd doctor downgrades the system lookup rung to a warning when a VPN tunnel is up, and the UI status watcher debounces transitions across two ticks so a momentary blip while a VPN connects cannot latch the pill. Separately, PHP inside the containers could not reach VPN-internal API endpoints until lerd was restarted, because the lerd network's aardvark-dns was left on the pre-VPN forwarders and a stale cache. The watcher now fingerprints the host resolver environment every tick and, when it changes, re-syncs the container network DNS automatically, re-pointing aardvark-dns at the current resolvers and reloading the network so containers pick them up with a fresh cache. The re-sync runs on Linux only, since macOS containers resolve through the podman machine VM. Reported in #394.

[1.21.2] — 2026-05-22

A hotfix for three issues a user hit while migrating an Ubuntu development stack to Lerd: a spurious MySQL readiness warning printed in front of every PHP command, a fatal parse error from the dump bridge on legacy PHP, and a failed PHP 7.2 image build.

Fixed

  • could not start mysql warning on every PHP command even though MySQL was healthy. The readiness probe ran mysqladmin ping with no host, so the client fell back to the default Unix socket at /var/run/mysqld/mysqld.sock. MySQL images that keep their socket elsewhere, and MariaDB which uses a different path again, failed the probe for the full 30 second timeout while the server was up and serving connections, leaving a mysql is active but not yet ready warning in front of every command. The probe now connects over IPv4 loopback TCP, which is independent of the image's socket path and, because it targets the container's own loopback, stays correct on macOS and on IPv6-only host networks. The MySQL service-migration probe, dump, and restore commands force TCP the same way so lerd service migrate is consistent with the Postgres path.
  • Dump bridge crashed legacy PHP with a fatal parse error. The dump() / dd() bridge that Lerd installs as an auto_prepend_file was written with match, mixed and never type declarations, str_contains / str_ends_with, and array_key_first, none of which exist on PHP 7.4 (and, for several of them, 8.0). On those versions PHP aborted with syntax error, unexpected '=>' before running any code, breaking every CLI command and web request until the mounted ini was removed by hand. The bridge is rewritten in syntax that parses and runs on every PHP version Lerd builds, down to the 7.2 floor, with identical behaviour on current PHP.
  • lerd php:rebuild 7.2 failed to build the image. PHP 7.2's Alpine 3.12 base predates several things the Containerfile assumed: the icu-data-full package, gd's post-7.4 configure flags, current phpredis and xdebug, the bat package, and a transitive libgomp that the ImageMagick extension needs at runtime. icu-data-full now installs tolerantly because older Alpine folds the full ICU locale data into icu-libs, gd's configure branches on PHP_VERSION_ID, xdebug and phpredis are pinned to the last releases supporting the older PHP line, bat installs tolerantly, and libgomp is added explicitly so ImageMagick loads. PHP 7.2 stays an unshipped legacy tier with no pre-built base image, but a local lerd php:rebuild 7.2 now produces a working image.

[1.21.1] — 2026-05-19

A targeted fix for npm install -g and the same long-standing issue for composer global require. npm globals went into a fnm version-specific bin directory that nothing has on PATH, so npm install -g pm2 succeeded but the resulting pm2 was unreachable from the user's shell. Composer globals had the same shape, landing in ~/.config/composer/vendor/bin/ which is also not guaranteed on PATH (especially on macOS Homebrew installs), so composer global require psy/psysh left psysh callable only via its full path. lerd now points npm's prefix at a stable per-user path under ~/.local/share/lerd/node-global/, mirrors composer's existing global vendor/bin, and writes small wrapper scripts for every global binary from either source into ~/.local/share/lerd/bin/, the directory lerd install already adds to PATH on both Linux and macOS regardless of whether lerd itself came from Homebrew or a curl-pipe install. Node wrappers exec the real bin through fnm exec --using=default, composer wrappers exec through lerd php, so #!/usr/bin/env node and #!/usr/bin/env php shebangs both resolve against lerd's managed runtimes from any directory. npm uninstall -g and composer global remove clean their wrappers alongside the package. Each category uses its own marker so the two syncs coexist without removing each other's wrappers, and files in the bin dir without any lerd marker (existing node, npm, npx, php, composer, laravel, fnm, mkcert shims, plus anything the user dropped in by hand) are never touched. The node, npm, npx, and composer shell shims route through the lerd binary first so the new behaviour applies to bare npm install -g and composer global require, not only to lerd npm and lerd composer, falling back to a direct fnm or composer.phar invocation in containers where the glibc lerd binary cannot exec.

Fixed

  • Globally installed npm packages were orphaned in fnm's version directory (#390). npm install -g pm2 finished cleanly but pm2 was not callable because the binary landed under ~/.local/share/fnm/node-versions/<v>/installation/bin/, which is not on the host PATH. lerd now sets npm_config_prefix=~/.local/share/lerd/node-global for every npm and npx invocation routed through its shim, and after the command exits it mirrors every executable in that prefix's bin/ to a wrapper script in ~/.local/share/lerd/bin/. That directory is the one lerd install adds to PATH via the user's shell rc on every supported platform, so the wrappers are reachable from a fresh shell whether lerd itself was installed via Homebrew on macOS, curl-pipe on Linux or macOS, or a packaged distro build. The wrapper exec's through fnm exec --using=default so the #!/usr/bin/env node shebang resolves under the fnm-managed default node regardless of cwd, project-pinned versions, or whether the tool is invoked from outside any project. Uninstalls remove the wrapper too. Files in ~/.local/share/lerd/bin/ without lerd's marker comment are left alone, so the existing node, npm, npx, php, composer, and laravel shims that share the directory are never clobbered, and the head-only marker check rejects native binaries (which can carry the marker bytes as a string constant) so the cleanup pass cannot accidentally remove lerd itself or any other Go binary that happens to live nearby.
  • Bare npm install -g did not route through the new prefix logic (#390). The node, npm, and npx shims in ~/.local/share/lerd/bin/ previously called fnm exec -- <bin> directly, so the prefix override only applied when the user explicitly typed lerd npm. The shims now delegate to the lerd binary first via exec "$LERD" <bin> "$@" whenever it is reachable, and fall back to the original direct-fnm path inside containers where the glibc lerd binary cannot exec. The friendly "No Node.js version available via lerd. Run: lerd node:install 22" hint surfaces through both paths.
  • composer global require left binaries unreachable from PATH (#390). The composer flow had the same shape as the npm one: composer global require psy/psysh succeeded but the resulting psysh lived under ~/.config/composer/vendor/bin/, which is not guaranteed on PATH (worst on macOS Homebrew). A new lerd composer subcommand now wraps the existing lerd php composer.phar flow and, after composer exits, syncs $COMPOSER_HOME/vendor/bin/ to ~/.local/share/lerd/bin/ as small wrapper scripts that exec the real bin through lerd php so the #!/usr/bin/env php shebang always resolves against the FPM container's PHP. Composer wrappers carry their own marker so they coexist with npm wrappers in the same directory without either sync removing the other's files, and the existing hand-written laravel shim (which has no marker) is preserved as a foreign file so users who relied on the install-time shortcut keep working. The shell composer shim delegates to lerd composer first when the lerd binary is reachable, falling back to the original lerd php composer.phar path otherwise. RunPHP grows a non-exiting sister RunPHPCapture so the sync can run on every exit path, including failed composer global remove runs that leave the bin dir half-cleaned.

[1.21.0] — 2026-05-19

The 1.21.0 line ships desktop notifications: a single in-process notifier inside lerd-ui fans every event out across the dashboard WebSocket and RFC 8291 Web Push, so captured mail, worker failures, finished service operations, registry update transitions, and ray/dump arrivals reach you even when no tab is open. The PHP-FPM image grows a real shell environment (zsh + starship + a handful of modern CLI tools, all isolated from the host) and then loses about 800 MB of build toolchain in a multi-stage split, dropping localhost/lerd-php85-fpm:local from 1.36 GB to 535 MB without losing any of the 68 PHP modules it ships. A new on-demand commands feature surfaces one-shot framework actions (cache clears, migrations, login URLs, anything you put in yaml) across the dashboard, the lerd run CLI, the command palette, and four new MCP tools, all backed by a generalised Dropdown component that replaces every native select in the UI. The site detail header gets a browser-style address bar with the favicon, TLS lock, LAN-share chip, and worktrees promoted from a dropdown to tabs, an Env tab joins Overview/Tinker/Dumps to show the project .env verbatim, and tray menu picks up Dump bridge and Notifications toggles that update live via a new KindDumpsStatus event. Postgres grows 17 and 18 alternates alongside a new MySQL 9.7 LTS line, all gated by a canonical-version pin so flipping the yaml's canonical no longer silently major-jumps existing installs. Plus Türkçe (tr) joins the dashboard languages, a public_dir override lands in .lerd.yaml for projects with a non-standard document root, every git invocation in the tree now flows through internal/git, and worker-failure pushes are batched so a systemd cascade no longer fires six near-identical notifications back to back.

The path to stable then folded in three transitive dependency bumps closing five Dependabot alerts (jwt-go to 5.2.2 for the header-parsing memory-allocation flaw, svelte to 5.55.8 for the DOM-clobbering and SSR-spread XSS paths plus hydratable promise serialization XSS, kysely to 0.28.17 for the JSON-path traversal injection), a LAN-exposure audit that closes three dashboard endpoints that were reachable on lan:expose installs (raw .env, push-test, and an unauthenticated mailpit webhook) and adds path-traversal validation for the new public_dir override, a catatonit PID 1 swap for mysql and mariadb so podman stop returns in a second instead of timing out at 30s, a host-worker stop-stays-stopped fix that also threads lerd's bin directory onto PATH for npm-spawned subprocesses so wayfinder and friends can find php, the restoration of git in the PHP-FPM runtime stage that the multi-stage split dropped, notification clicks that finally land on the right tab, and a container.target field in .lerd.yaml that lets multi-stage Containerfiles pick a development or production stage instead of always building the trailing one.

Added

  • Desktop notifications via Web Push with per-category settings (#353). Five notification kinds ride a single in-process notifier inside lerd-ui that fans every event out across /api/ws (open tabs) and Web Push (closed PWA / minimised tabs): captured mail from Mailpit, queue/horizon/reverb worker failures, finished service operations (install, migrate, reinstall, update, rollback) with success/failed variants, registry update-available transitions, and ray()/dump() arrivals with the dumped content as a single-line preview in the body. Push is RFC 8291-encrypted, signed with a per-install VAPID key pair stored under ~/.local/share/lerd, fans out from the central dispatchNotification chokepoint, and prunes subscriptions automatically on 410/404. The service-worker push handler is kind-agnostic, reading title, body, tag, url, and data straight off the payload so the SW never grows kind-specific branches. Settings live under System → Desktop notifications: master switch plus per-category toggles, a test-push button, and a subscribed-devices list with Forget actions. Per-kind preferences are stored both client-side in localStorage and server-side on the Subscription so closed-PWA Web Push honours the toggles too. Every visible string is translatable via Paraglide keys under notify_*, with the English fallback always travelling in the payload so the SW (no DOM, no Paraglide) keeps rendering correctly. Worker-failure detection no longer gates on whether a dashboard tab is visible; only the eventbus publish does, so a closed-PWA developer still gets the push when a queue worker dies.
  • Notifications page polish and dashboard health row (#354). Denied state now expands into a banner with the page origin, per-browser unblock instructions picked from navigator.userAgent (Chromium, Firefox, Safari, generic), and a recheck button so users who fix the permission in browser settings don't have to hard-reload to see the page transition out of denied. The panel follows the DetailPanel / DetailHeader shape used by DNS, Nginx, Watcher, and Dump-bridge, with a single Enable/Disable button in the header trailing slot next to a StatusPill and category-toggle rows that stretch to the full panel width. The sidebar dot, the System Health widget row on the dashboard, and the header pill now share one notifyEffectiveOn derivation (permission granted, not auto-subscribe-disabled, master on) so the three indicators agree in every state. The label across the UI is now just "Notifications".
  • Turkish (tr) locale for the dashboard (#355). Türkçe joins English, German, Spanish, French, Indonesian, Dutch, and Portuguese with 620 strings translated to full coverage. Glossary picked for technical accuracy and Türkçe convention: Dashboard → Pano, Service → Servis; Worker, Worktree, Horizon, Reverb, Stripe, FrankenPHP, and Tinker stay as-is. LOCALE_LABELS["tr"] = "Türkçe", LOCALE_CODES["tr"] = "TR", the inlang settings list, and the key-drift test all pick up the new locale.
  • Zsh + starship baked into the PHP-FPM image for lerd shell (#358). lerd shell and the TUI shell action previously dropped into bare sh, which made the in-container experience feel like a different planet from the host. The PHP-FPM image now ships zsh with a self-contained config (starship prompt, compinit -u, persistent history under ~/.local/share/lerd/shell-state/php-<ver>/zsh/) and a handful of modern CLI tools (eza, bat, fzf, zoxide). The quadlet sets HostName= to the host's hostname so the prompt reads root@your-machine instead of an opaque container id, and the renderer mounts the per-version zsh history directory read-write so commands survive container rebuilds. InteractiveShellScript() picks zsh in the FPM image and falls back through bash to sh for other containers. The shell environment is deliberately isolated from the host's ~/.zshrc and ~/.config/fish: an earlier draft mounted those, but every customised host config introduced its own cascade of "no such file or directory" errors as it tried to source distro-specific paths the alpine container had never heard of. The container shell is its own world now, consistent across every machine and contributor.
  • Postgres 17 and 18 alternates, MySQL 9.7 LTS, canonical version pinning across multi-version presets (#361). Postgres is restructured into a multi-version preset with 16 canonical plus 17 and 18 alternates, MySQL gains the new 9.7 LTS line as an alternate, and the obsolete MySQL 8.0 alternate is removed because 8.4 LTS already covers that line. Multi-version presets now snapshot the canonical version tag at install time into ServiceConfig.CanonicalVersion, so a future release flipping the yaml's canonical (e.g. postgres 16 to 18) will no longer silently major-jump existing installs. The pin survives reconciles, gets backfilled from the running image for pre-pin installs, and is updated by lerd service migrate when the user moves the canonical on purpose. Postgres 18 needed PGDATA pinned to the legacy /var/lib/postgresql/data path because the upstream image moved its default into a version subdirectory and refuses to start otherwise; MySQL 9.x removed mysql_native_password entirely, which was named in the shared lerd.cnf's authentication_policy line and aborted init, so that line is gone and the default caching_sha2_password is used on every supported version. pgadmin now auto-discovers postgres family members the same way phpmyadmin discovers mysql and mariadb: a dynamic_env directive registers it as a family consumer, the servers.json and pgpass file mounts are generated at materialise time from ServicesInFamily, and PGADMIN_REPLACE_SERVERS_ON_STARTUP makes pgadmin re-import on every restart so installing a postgres alternate immediately shows up in the dashboard without a manual server-add step. To support that, FileMount gained an optional ContentFn generator alongside the static Content field. Closes #360.
  • On-demand framework commands across UI, CLI, and MCP (#363). A new commands feature surfaces one-shot admin actions (cache clears, migrations, login URL generation, anything you put in yaml) for every registered site. The dashboard exposes a Commands dropdown in the site controls band, the CLI exposes lerd run <name>, and the command palette aggregates commands across all sites with a single multi-term search. MCP gains four tools so AI assistants can list, run, add, and remove commands without hand-editing yaml. The Go side introduces a FrameworkCommand schema (yaml-defined or inline in a project's .lerd.yaml), a ResolveCommands merge function that lets projects override or disable framework defaults by name, a streaming run endpoint that interleaves stdout and stderr over SSE with per-site mutual exclusion and loopback gating, and an output: terminal mode that spawns the user's terminal emulator instead of streaming. lerd check validates the schema. Built-in Laravel and Symfony come with the same command set hardcoded so sites work before the framework store fetch lands. The frontend introduces a generalised Dropdown component (object options, keyboard navigation with arrow keys and type-ahead, ARIA roles, mobile-aware popover sizing) that replaces every native select in the UI for visual consistency. The commands feature uses it for both the per-site Commands menu and the inline run modal, which renders streamed output with ANSI color support and distinguishes stderr from stdout. Run state is global so the palette and the dropdown share the same modal; finished runs are kept in localStorage so users can review previous output without re-running, and long runs that finish while the tab is hidden trigger a desktop notification when permitted. Docs land in docs/features/commands.md and the MCP skill picks up updated tool descriptions.
  • Multi-stage PHP-FPM build cuts image size by ~60% (#364). The PHP-FPM image was carrying about 800 MB of build toolchain (autoconf, make, g++, linux-headers, and a long list of -dev headers) that pecl and docker-php-ext-install need at compile time but nothing needs at runtime. Splitting the Containerfile into a builder stage that compiles every extension and a runtime stage that only ships musl runtime libs plus the compiled .so files brings localhost/lerd-php85-fpm:local from 1.36 GB to 535 MB on top of #358, with no functional regressions. A new buildCustomExtRuntimeDeps helper emits a single apk RUN line in the runtime stage that reinstalls the apk packages user-configured extensions need; without it, custom extensions like imap (which needs c-client at runtime, not just at build time) would compile in the builder fine but fail to dlopen at runtime. The well-known imap case verified the round-trip works. baseContainerfileHash strips the new {{.CustomExtensionsRuntime}} placeholder so pre-built base image cache keys stay stable. Every PHP extension (68 modules including imagick, redis, mongodb, igbinary, pcov, xdebug, opcache, all base extensions), composer, node + npm, ghostscript, imagemagick, ffmpeg, mysql-client, and the zsh/starship/etc shell environment from #358 are preserved. The base-images workflow now strips the new placeholder when hashing the Containerfile, and after each per-arch push a smoke step pulls the just-built amd64 image, counts loaded PHP modules, fails if the count drops below 50, and verifies a handful of load-bearing extensions (curl, gd, intl, mbstring, pdo_mysql, redis, xdebug, zip) are present plus that PDO has its three drivers and composer runs, so silent extension drops get caught at publish time instead of on user rebuilds 24 hours later.
  • Browser-style address bar header for the site detail panel (#365). The site detail panel now renders its top region like browser chrome. The dominant element is a pill-shaped address bar with the favicon, scheme, and domain, flanked by a circular reload, pause/resume, open-in-browser, a LAN-share toggle, terminal (loopback only), and unlink. The TLS lock at the leading edge of the pill clicks through to flip HTTPS, with the gating logic from the old SiteControls toggle ported over so it stays static when DNS is off. Clicking the URL itself opens the Manage Domains modal, leaving the explicit Open button as the way to launch the site in a browser. When LAN sharing is on, the existing teal URL chip with its hover-QR slots into the bar next to the framework badge instead of living below the controls row. Worktrees are tabs above the bar rather than a dropdown: the first tab is the main checkout with a home glyph, every other tab carries the branch icon and an inline X. The X opens a new RemoveWorktreeModal scoped to that branch, and the trailing + opens AddWorktreeModal. The old combined WorktreeModal is gone because the tab strip already shows every worktree and removal is one click away. Side effect: openSiteInBrowser no longer ignores TLS on worktrees, so worktree subdomains correctly open as HTTPS when the parent site has TLS enabled, matching what nginx and the cert manager have always done.
  • Env tab on the site detail panel (#366). A new Env tab joins Overview, Tinker, and Dumps that renders the site's project .env file as raw, selectable, copyable text. The file shows verbatim, comments, blank lines, and ordering preserved, with a one-click copy button mirroring the existing services-side EnvBlock. The tab only appears when the site actually has a .env file on disk, so non-Laravel or static sites that never had one stay clean. A new has_env field on the sites snapshot makes that gate cheap on the frontend, and a new GET /api/sites/{domain}/env endpoint serves the raw bytes, accepting the same ?branch= parameter the rest of the site routes use so the tab follows the active worktree selector just like Tinker does. EnvBlock now accepts either a parsed vars map (existing service callers) or a raw text prop (the new site tab), keeping the styling and copy logic in one place.
  • public_dir override in .lerd.yaml (#370). Projects with a non-standard document root (e.g. a Laravel skeleton using public_html/ instead of public/) can now declare it directly in .lerd.yaml without having to clone the entire framework definition. The Site struct already had a PublicDir field and resolvePublicDir() already preferred it over the framework default; what was missing was a route to populate it from the per-project file. ProjectConfig gains a public_dir field, lerd link threads it into the site, and lerd init preserves it on re-runs. The existing nginx vhost templates already use the resolved value, so no template churn was needed. Also promotes the Configuration page from /reference/configuration to a top-level /configuration entry in the docs nav and documents the new field with a short Custom public folder example. Closes #369.
  • Dump bridge and Notifications toggles in the tray menu, live dump-status updates on the dashboard (#373). The tray picks up two new items, Dump bridge and Notifications, alongside a global lerd notify on/off/status command. Notifications gain a config-level mute via notifications.disabled in config.yaml, which the central dispatchNotification chokepoint now consults so neither the WebSocket banner broadcast nor the Web Push fanout runs while the gate is closed. Per-device dashboard prefs still apply on top when the gate is open. The CLI subcommand and the tray click both flow through the same path so the surface stays consistent. The dump-bridge indicator on the dashboard now updates live: dumpsops.Apply still owns state, but lerd-ui learns about it three ways. The dashboard's /api/dumps/toggle and /api/dumps/passthrough are wrapped with publishAfter so cross-tab updates fan out, a new loopback-only /api/dumps/notify-changed endpoint lets the CLI nudge the daemon after lerd dump on/off, and a new KindDumpsStatus eventbus kind broadcasts buildDumpsStatusJSON over the WS. The frontend's dumps store subscribes to that frame and writes incoming payloads straight into the existing status writable, so the antenna dot, the system sidebar indicator, and the dashboard health widget all flip without a refresh. Tray clicks stop waiting for the next 30s poll too: every toggle handler shares a single refresh callback that fetches a fresh snapshot the moment the underlying lerd command exits, so the menu label reflects the post-command state right after the click instead of lagging by up to half a minute.
  • container.target field in .lerd.yaml for multi-stage Containerfile builds (#385). Projects whose Containerfile defines separate development and production stages can now declare container.target: development in .lerd.yaml and lerd threads --target <name> into both the synchronous lerd link build and the streaming UI rebuild. A shared buildCustomImageArgs helper keeps the two code paths from drifting, and the cache key in hashContainerfile mixes the target value in with a NUL delimiter so flipping between stages on an unchanged Containerfile actually rebuilds instead of serving the previously-built stage. Docs and the MCP skill table pick up the new field. macOS needs no extra wiring since the flag rides on the standard podman build argv that already crosses into the podman-machine VM. Addresses #379.
  • MCP service_add accepts the init flag (#386). The init field landed on CustomService and the preset schema in #383 so the quadlet generator can wire podman's --init when an image's main process ignores SIGTERM as PID 1, but the MCP service_add tool never picked up the corresponding argument. An agent registering a custom service through the wire couldn't turn the flag on without editing the YAML by hand. The tool's input schema now advertises init alongside the other CustomService fields, so MCP-driven workflows reach feature parity with the .lerd.yaml path.

Changed

  • Every git invocation in the tree now flows through internal/git (#356). Seven production call sites outside internal/git/ were spawning git via exec.Command directly, each with its own stdout/stderr wiring. They now route through four new helpers in internal/git/exec.go so the package owns every git invocation in the codebase. The new surface: Output(dir, args...) (string, error) captures stdout, Run(dir, log, args...) sends both stdout and stderr to a single writer (the modal-log pattern), RunTTY(dir, args...) wires stdout/stderr straight to the user's terminal so shell redirects still work, RunCaptureStderr(dir, args...) mirrors stdout to the terminal while teeing stderr into a buffer the caller inspects for git's "use --force" hint, and BranchExists(dir, branch) wraps the show-ref --verify pattern. Routed sites: internal/cli/worktree_ui.go (branch-exists, worktree add, worktree remove), internal/cli/worktree_add.go, internal/cli/worktree_remove.go (worktree remove with force-detection plus the generic runGit), and internal/ui/server.go (runGitOutput wraps Output). Each preserves the original cmd.Dir, stream wiring, and exit-code semantics. The only behaviour delta is the wrap text on errors, and no caller pattern-matches on that text.
  • Batched worker-failure notifications (#372). A systemd cascade like start-limit-hit hitting half a dozen queue workers at once, or an OOM kill taking out every worker for a site in the same tick, used to fire one push per unit and spam the user with five or six near-identical "Worker failed on …" notifications back to back. The watcher detects new failures every five seconds, so the bursts were tightly clustered and unavoidable on the wearer side. The watcher now hands its new-failure delta to a small batcher that buffers by unit and arms a single five-second flush timer. Failures arriving later in the same window join the in-flight batch without resetting the timer, so the grouped push lands at most five seconds after the first failure even under a sustained cascade. A single isolated failure still goes out with the existing per-unit shape (deep link to the affected site, per-unit tag for browser dedupe), while two or more collapse into one "N workers failed" payload listing every worker@site and tagged lerd-workers-group so a later grouped push supersedes the earlier one in the notification tray instead of stacking. Matching notify_worker_failed_group_title and _body keys are added to all eight locale message files, with the Turkish strings translated and the rest falling back to English the same way the existing worker_failed entries do.

Fixed

  • Worktree-manager button rendered on non-git sites (#357). The gear icon next to the worktree picker in the site header opens the worktree-management modal, but on a site without a git repo there's nothing for that modal to manage. The button gated only on site.paused, so it appeared anyway on non-git linked projects. The WorktreePicker on the same row already gates its passive git:(branch) label on site.branch being set; the button now reuses the same condition so the two affordances behave consistently. No git, no worktree UI.
  • Adding or removing a secondary domain on a secured site left the TLS cert untouched (#367). The five call sites that mutate a site's domain list (the CLI domain add and remove subcommands, the HTTP domain:add / domain:edit / domain:remove handlers, and the MCP equivalents) were all calling certs.IssueCert, which is a documented no-op when the cert file already exists. Since the primary domain's cert always exists for a secured site, the new SAN list was silently discarded and browsers kept rejecting the new hostname with "connection is not private" until the cert was nuked by hand. Every domain-mutating caller now goes through certs.ReissueCertForWorktree, which enumerates the site's worktrees via gitpkg.DetectWorktrees, combines them with site.Domains, and calls IssueCertForce so the cert atomically picks up the new SAN list while keeping existing worktree subdomains in scope. A focused unit test in the certs package and two handler-level tests in the ui package pin both regression vectors so neither this bug nor the worktree-subdomain regression that an earlier draft of this fix introduced can come back.
  • Streamed worktree install and post-v1.20.2 audit follow-ups (#368). Composer and npm output during lerd worktree add now streams to the UI modal via a cross-process flock (internal/git/install_lock.go); the watcher's auto-install skips when the UI holds the lock, and a 60-second periodic rescan recovers any worktree the UI left un-provisioned. WorktreeCheckoutPath now nests under the parent site (<site>/<base>-<slug>) and writes a /<base>-*/ entry to .git/info/exclude. Other fixes in the same pass: the UI dropdown's scrollbar is visible again (dropped no-scrollbar) and wheel scrolling inside the menu no longer closes it, same fix to CommandsDropdown; the UI commands runner prepends BinDir to PATH so php/composer/npm shims resolve under launchd's restricted PATH on macOS, refuses to run when ?branch= doesn't resolve to a real worktree, and defer-recover wraps the SSE pipe goroutines; macOS launchd_darwin.containerToPodmanArgs now emits --hostname so lerd shell shows root@<host>; service migrate syncs CanonicalVersion correctly for postgres/postgis-style image tags (18-3.6-alpine) via matchVersionByImageTag; the worker health watcher now seeds health state before the tick loop so a clean start followed by a new failure dispatches (the previous fix silently swallowed the first failure on clean start); push gains a success log line and endpointShort uses url.Parse; notify.ts switches from a single lastTag string to per-kind plus 2s-window dedupe; plus a UTF-8-safe dumpPreview, logged parse errors on notifyOnServiceUpdates, base-images smoke that runs on arm64, and a platform-aware shell hint.
  • Tinker swallowed bare-expression results when the dump bridge was enabled (#371). When the dump bridge is on, its custom dump() ships the value to the dashboard socket but stays silent on stdout unless dumps.passthrough is also on. Tinker auto-wraps bare expressions like User::count() in dump(), so the bridge was silently swallowing the result and the REPL returned an empty block unless the user reached for echo. Setting LERD_DUMP_PASSTHROUGH=1 on every tinker podman exec keeps the dashboard capture intact while letting Symfony VarDumper also print to stdout, restoring the value in the tinker output regardless of the global passthrough setting.
  • Stopped host workers resurrected on the next fsnotify event or launchd heal tick (#375, #376). A host worker that the user stopped via the UI or lerd worker stop was getting silently revived. Two paths were doing it: every HEAD write inside a worktree (commit, checkout, rebase, branch rename) fired the fsnotify "changed" handler, which was unconditionally calling AutoStartOptedInWorktreeWorkers and re-bootstrapping every opted-in host worker; and on macOS the launchd heal loop additionally treated a missing plist as drift and recreated it, even though WorkerStopForSite removes the plist precisely to represent user intent. A new shouldAutoStartWorkersOnSync helper gates the change handler to action == "added" so HEAD movement leaves existing units alone, and shouldHealOnReason skips "plist missing" and only heals genuine drift like "not loaded in launchd" or "loaded but no live process". The same report also flagged that wayfinder's php artisan wayfinder:generate --with-form failed inside npm run dev with /bin/sh: php: command not found because the launchd guard script and the systemd unit for host workers never put lerd's bin directory on PATH; both paths now prepend config.BinDir(), and the Linux unit re-adds ~/.local/bin so systemd-user's inherited search path doesn't quietly narrow. Closes #381. Also localizes four hardcoded English literals in the new address-bar header from #365 (TLS toggle, LAN toggle, mobile-overflow LAN entry, overflow menu aria-label) so non-English users (Turkish shipped in #355) stop seeing English on the most-touched UI of v1.21.0.
  • VCS-typed composer repositories broke after the multi-stage split (#377). The PHP-FPM rebuild in #364 cut image size by moving git into the builder stage, where it was only needed for the phpredis/imagick fallback clones. The runtime stage lost it without anyone noticing. Composer falls back to dist for most flows, but "type": "vcs" repositories declared in composer.json hard-require a git clone, and any plugin that shells out to git for tag detection or repo state failed the same way. Re-adding git to the runtime apk add brings the previous behaviour back at the cost of a few megabytes, and a smoke test on the embedded Containerfile catches a future regression at publish time.
  • Three dashboard endpoints were reachable from the LAN when lan:expose was on, and public_dir accepted path traversal (#382). GET /api/sites/{domain}/env handed out the raw .env (APP_KEY, DB credentials, third-party tokens) once the user enabled lan:expose, POST /api/push/test let a LAN attacker spam pushes onto subscribed devices, and POST /api/webhooks/mailpit was bypassing the remote-control gate unconditionally so any reachable host could fire fake mail notifications with attacker-controlled subject and from. The first two now ride the existing loopbackOnlyRoutes and loopbackOnlySiteSubactions lists in withRemoteControlGate so they 403 from non-loopback callers regardless of LAN or remote-control config. The mailpit bypass keeps the pre-auth path mailpit needs but tightens the check to fromHost(r), which verifies the source IP belongs to one of the host's interfaces; pasta on Linux and gvproxy/vmnet on macOS source-NAT the container to that address, while a LAN attacker arrives from elsewhere and a spoofed SYN breaks the TCP handshake because SYN-ACK routes back into the host. fromHost strips IPv6 zone suffixes, parses sources with net.ParseIP, and uses IP.Equal so v4, v6, zoned link-local, and v4-mapped-v6 sources all compare correctly. Separately, the public_dir override added in #370 was concatenated into the nginx vhost's root directive without validation, so cloning a hostile repo into a parked directory could pivot the document root via ../../etc. A new ValidatePublicDir rejects absolute paths, leading ~, NUL bytes, and any .. segment; LoadProjectConfig silently falls back to the framework default with a warn when the value is bad, and resolvePublicDir double-checks at render time as defence in depth.
  • mysql and mariadb podman stop hung the full grace window (#383). mysqld in mysql 8.4 (and mariadb, which shares the same daemon entrypoint shape) does not install a SIGTERM handler when it runs as PID 1, so podman stop waited the full 30s before SIGKILL and systemctl restart wedged around the 30-90s mark. A new init flag on CustomService and the preset schema threads through to PodmanArgs=--init in the quadlet generator, and the mysql and mariadb presets turn it on. Podman injects catatonit as PID 1, mysqld lands at PID 2 with its normal signal-handling thread intact, podman stop completes in ~1s, and lerd service restart returns instead of timing out. The flag is opt-in per preset since postgres, redis, and the rest of the bundled services already handle PID 1 signals correctly. Closes #380.
  • Notification clicks landed on a non-existent or wrong tab (#384). Two of the dashboard's push notifications carried URLs the router couldn't resolve: the dump notification used #dumps (not a top-level tab, so parseHash silently fell back to dashboard) and the worker_failed notification used #sites/<name> (Sites is keyed by primary domain, so on any site where name and domain differ — whitewaters with theregistry.test, for example — the click landed on an empty state). Both now route through a small siteDomainForRoute helper that resolves a registered site name to its primary domain at notification build time, falling back to the input verbatim when no site matches so test fixtures and unlink races stay safe. The dump notification points at #sites/<domain>/dumps, the worker_failed URL becomes #sites/<domain>, and SiteDetail picks up an optional sub-tab segment from routeRest via a $effect that overrides the localStorage-restored tab when the segment names a known sub-tab.

Security

  • Three transitive dependency bumps close five Dependabot alerts on main (#387). github.com/golang-jwt/jwt/v5 moves from 5.2.1 to 5.2.2 to close the header-parsing memory-allocation flaw (high). Svelte goes from 5.55.6 to 5.55.8, covering the three open medium-severity advisories: the DOM-clobbering XSS path, the SSR spread-attribute XSS, and the hydratable promise serialization XSS. Kysely steps to 0.28.17 to clear the JSON-path traversal injection (high), pulled in transitively by @inlang/paraglide-js at build time. The Go-side JWT is an indirect dep with no direct call sites in lerd, so runtime exposure was small even before the bump. Svelte is the real one since the dashboard renders user-controlled site names, env values, and dump payloads, so each of those XSS paths was a live concern for any LAN-shared or remote-controlled install. Kysely is only reachable through paraglide's build-time SDK so runtime impact was nil, but the lockfile drift was noisy.

[1.20.2] — 2026-05-14

A lerd lan:share follow-up that fixes the bugs uncovered while sharing a real Laravel + Vite + RustFS site over the LAN. The tunnel share proxy was passing gzipped responses through untouched, so on most frameworks the page loaded but every asset URL still pointed at the local .test domain and the share rendered white. The LAN share proxy was setting X-Forwarded-Host but not X-Forwarded-Port, so Symfony's Request::getSchemeAndHttpHost() and Ziggy's URL helper composed asset URLs out of the proxy's host plus nginx's $server_port=443, leaking http://<lan-ip>:443/... into the page. Behind that was an nginx-side bug: every site's vhost did fastcgi_param HTTP_X_FORWARDED_PORT $server_port and unconditionally overwrote whatever the upstream proxy sent. Vite dev-server URLs (http://[::1]:5173/...) and RustFS / S3 / other loopback URLs (http://localhost:9000/...) were also leaking into LAN-shared pages and failing on the client device, including from the JSON-escaped form Inertia.js emits in data-page attributes. Toggling TLS on or off left running share listeners pointing at the old backend port until a manual restart. And under the hood, lerd secure / lerd unsecure had three drifting copies (CLI, dashboard, MCP); MCP was missing both the Stripe listener restart and the LAN share refresh, and any new step added today was at risk of being forgotten in two of the three places. The consolidation lands siteops.SetSecured as the single source of truth for the TLS toggle flow: every entry-point now calls one function with no per-caller variation, and all post-toggle work (Stripe webhook restart, LAN share rebind) goes through HTTP notifications to the daemon so adding a new step happens in exactly one place.

Fixed

  • White page on lerd share for gzipped responses. The tunnel share proxy was reading Content-Encoding and bailing out of body rewriting whenever the upstream returned gzip, which is essentially every framework. The proxy now sends Accept-Encoding: identity to the upstream and transparently decodes gzip when the upstream sends it anyway, so absolute asset URLs in HTML/CSS/JS finally get rewritten to the tunnel host. Mirrors what the LAN share proxy was already doing.
  • Ziggy and Symfony emitting http://<lan-ip>:443/... URLs on LAN-shared sites. Two-layer fix: the LAN share proxy now forwards X-Forwarded-Port to the upstream, and the nginx vhost template stops unconditionally overriding it with $server_port. A new shared _forwarded.conf map ($real_forwarded_port) reads the upstream header when present and falls back to $server_port only when the request was direct. Both the SSL and non-SSL vhost templates were updated, and the running per-site confs were patched in place so the fix took effect immediately for all 12 sites without needing to re-link every one.
  • Vite dev server unreachable from LAN devices. http://[::1]:5173/... URLs that the laravel-vite-plugin emits are rewritten in the response body to http://<lan>/__lerd_vite__/5173/..., and the share proxy forwards those paths to the local Vite. Vite HMR's WebSocket upgrade routes to the same listener (no per-project vite.config.js change needed). Transitive imports (/node_modules/..., /@vite/..., /@id/..., /@fs/..., /@react-refresh, /__vite_ping, plus framework source roots /resources/, /src/, /vendor/) reach Vite via the most recently observed Vite port for the share. Files with dev-only extensions (.vue, .ts, .tsx, .jsx, .mjs, .cjs, .svelte, .scss, .sass, .less, .styl, .astro, .mdx) and Vite-stamped query hints (?import, ?worker, ?raw, ?inline, ?url) route to Vite when a port has been observed.
  • RustFS / S3 / other loopback services on LAN-shared pages. The same body rewriter that catches Vite also normalises http://localhost:9000/... / http://127.0.0.1:9000/... / http://[::1]:9000/... references to flow through the share proxy. Avatar images, Mailpit links, and any other dev-time loopback URL now load from LAN devices without per-project config. The rewriter also handles the JSON-escaped form (http:\/\/localhost:9000\/...) that ends up in Inertia.js data-page payloads. The handler only marks a port as the active Vite when the stripped path actually looks Vite-served, so an avatar fetch via /__lerd_vite__/9000/... no longer poisons subsequent /node_modules/ lookups (which would otherwise hit RustFS and return 400 InvalidBucketName).
  • Toggling TLS no longer leaves the LAN share pointing at the old backend. lerd secure / lerd unsecure, the dashboard padlock, and the MCP site_tls tool now refresh any running LAN share proxy so it re-binds to the new backend port (80 vs 443). Previously the share kept proxying to the previous port until manually restarted.
  • MCP site_tls now syncs Vite Reverb env vars and refreshes Stripe / LAN share. Brought up to parity with the CLI: APP_URL plus VITE_REVERB_HOST/SCHEME/PORT get updated on every toggle (so browser Echo doesn't stay wedged on wss://host:80 after going to HTTP), the Stripe listener restarts with the new scheme so its --forward-to keeps working, and any running LAN share proxy re-binds to the new backend. Tool description updated to reflect the actual surface.

Changed

  • siteops.SetSecured is now the single source of truth for the TLS toggle flow. CLI, dashboard, and MCP previously each had inline copies of the eight-step toggle dance (cert, registry, .env, .lerd.yaml, nginx reload, Stripe restart, LAN share refresh). They drifted: MCP only updated APP_URL, the dashboard never restarted Stripe, and the LAN share refresh existed only on the CLI side. The new helper runs all the core steps and posts to the daemon's stripe:refresh and lan:refresh HTTP endpoints so any caller, including the daemon itself, drives the dependent listeners through the same code path. Adding a new step now happens in one place instead of three. CLI's restartStripeIfActive is exported as cli.RestartStripeIfActive so the new stripe:refresh daemon action can call it.
  • Tunnel share proxy in lerd share now matches lan:share's gzip + body rewriting behaviour. The two were close-but-not-identical implementations; the host proxy now uses the same Accept-Encoding: identity upstream hint and gzip-decoding fallback the LAN proxy already had.
  • Loopback-URL rewriter terminator class includes ) and ; so CSS url(http://[::1]:5173/foo.png) declarations get rewritten too. Previously the regex only stopped on quote/slash/space, so background-image URLs in Vue scoped styles leaked through unchanged.
  • Referer-based Vite routing was deliberately dropped. The LAN share listens on 0.0.0.0, so trusting a Referer for the target port would let any device on the network craft a Referer: http://host/__lerd_vite__/22/anything and steer the proxy at SSH or the database. Routing now uses only the in-process activeVitePort that gets set when a real /__lerd_vite__/<port>/ request comes in. Active-port fallback was already covering every legitimate case the Referer path was hitting.
  • Speculative path prefixes (/app/) and query hints (?v=) removed from the Vite router. /app/ was added defensively but breaks Filament admin URLs which sit under /app/. ?v=hash is the most common cache-busting query on the web; matching it routed unrelated Laravel/Symfony assets to Vite. Both removals are safe because the explicit Vite prefixes (/@vite/, /node_modules/, etc.) plus the dev-only extension list already cover everything Vite actually serves.

[1.20.1] — 2026-05-14

A 1.20.0 follow-up for the curl|bash / wget|bash install path. When the installer script is piped through bash the script's own stdin is the pipe, not the terminal, and that pipe was inherited by lerd install so its [Y/n] prompts hit EOF the moment they were issued. The Node-management and DNS questions flashed past without ever showing up to the user, both silently defaulted to yes, and the mkcert step then jumped straight to a sudo password request that looked like it came out of nowhere. lerd install now reopens /dev/tty for its prompts whenever stdin is not itself a TTY, and install.sh separately redirects lerd install's stdin from /dev/tty when one is available, so the prompts reach the user from either side of the pipe. If neither a TTY stdin nor /dev/tty is available (unattended CI, containers without a controlling terminal) the prompts now print (no terminal, defaulting to yes/no) instead of vanishing.

Fixed

  • Install prompts no longer skipped when piping the installer through bash (Ubuntu and other distros). lerd install falls back to /dev/tty when stdin is a pipe, and install.sh hands /dev/tty to lerd install so the Node-management and DNS questions actually reach the user during curl … | bash / wget … | bash installs.

[1.20.0] — 2026-05-14

The 1.20.0 line makes git worktrees a first-class concept throughout the stack. Per-worktree workers are surfaced in CLI, dashboard, and TUI; a built-in Vite dev server worker runs on the host with auto-start per worktree; wildcard cert SANs cover deep subdomains; env_overrides templating handles multi-tenant apps; per-worktree dump tagging keeps the live viewer accurate; and the dashboard ships a manage-worktrees modal alongside an upgrade to Tailwind CSS v4. The TUI catches up to the dashboard with service update and rollback keybinds, a failing-workers header pill, and per-worktree controls in the site detail pane. PHP 7.4 and 8.0 land as a frozen legacy tier, lerd php:ext add learns --apk-deps for extensions that need extra Alpine build packages, the dashboard ships in German, Indonesian, and Dutch with a wide i18n pass across all seven locales, and MCP gains workers_mode, bug_report, and a branch parameter across its per-worktree CLI wrappers. The post-beta fix queue covers macOS phantom launchd workers restarting healthy units every cooldown, dashboard update-banner glitches, and the worktree-add modal's "Automatic" resolver now announcing what it picked instead of going silent.

Added

  • Manage git worktrees from the site dashboard (#341). The site detail panel's path line gains a worktrees icon next to the branch picker that opens a modal for adding and removing worktrees, so the picker is no longer read-only. The modal lists each worktree with a link to its URL (https when the parent site is secured) and a trash button; Remove expands an inline confirm with Discard uncommitted changes (--force) and, for isolated worktrees, Also drop database (off by default, matching lerd worktree remove). Add worktree presents the same choices as the lerd worktree add prompts: a new branch (with optional base ref) or an existing branch (the dropdown lists local branches not already checked out plus remote-tracking branches, which git dwims into a fresh local branch), the database (share parent / isolated empty / cloned from main / cloned from another worktree / reuse-or-reset a preserved DB), a Laravel migrations checkbox, and the frontend-asset choice (asset worker / npm run <script> / skip), and it streams the git worktree add + dependency-install + build + DB-setup output live. The checkout directory is picked automatically (<project-path>-<branch>). Backed by extracted non-interactive helpers in internal/cli (RunWorktreeAdd, RemoveWorktreeAndCleanup, ApplyWorktreeDBChoice, ApplyWorktreeBuildChoice, RunWorktreeMigrations) shared with the CLI prompts, new HTTP endpoints GET /api/sites/worktree-options, POST /api/sites/worktree-add (SSE), a worktree:remove site action, and new locale strings across all seven languages. The site detail header also got a mobile-layout pass: the path and git:(branch) picker stack on separate rows on narrow screens, and the pause / unlink / open / terminal buttons move into a left-aligned full-width row instead of crowding the top-right corner.
  • Per-worktree worker lifecycle as a first-class concept, with dashboard surfaces (#319). Two new framework-yaml flags: per_worktree (worker can run independently per git worktree under lerd-<wname>-<site>-<wt>) and replaces_build (while running, the worker provides the asset manifest so the static npm run build step is unnecessary). The two flags answer independent questions ("where can this run?" vs "do we still need a build?") so future host-mode workers can mix and match. The watcher's syncWorktree now only auto-starts host workers the user opted into via .lerd.yaml workers: AND that declare per_worktree: true, replacing the previous blanket auto-start of every host: true worker. Dashboard surfaces: /api/services enumerates worktree workers per site with new worker_worktree / worker_worktree_domain fields, /api/sites worktrees carry framework_workers, SiteControls renders per-worktree toggles next to the parent toggles, and ServiceDetail builds /api/worker/<site-wt>/<name>/logs URLs for worktree-suffixed unit names. lerd setup uses GetFrameworkForDir so store-defined workers (vite) appear in the toggles and falls back to the parent site when run inside a worktree. runLink rejects worktree paths instead of registering them as separate top-level sites, eliminating the "main" entries that bled into the dashboard. lerd install runs refreshStoreFrameworks to re-fetch every cached framework yaml so users pick up the new flags without waiting for the 24h staleness check.
  • Built-in Vite dev server worker with host execution and worktree auto-start (#310). New vite worker for Laravel runs npm run dev on the host via fnm rather than inside the PHP-FPM container, so Vite has direct filesystem access for HMR without port publishing or a proxy. A new host: true field on FrameworkWorker lets future host-mode workers (Tailwind watcher, Encore, etc.) ship with the same execution profile. Per-worktree unit naming (lerd-vite-<site>-<wt>) allows simultaneous Vite instances across worktrees with auto-incremented ports. The vite definition lives in the lerd-frameworks store (laravel/11-13.yaml), not in the Go binary, so future framework workers don't require a lerd release.
  • env_overrides in .lerd.yaml for worktree env templating (#308). Projects can declare templated or static env variables that resolve when worktrees are created, layered on top of the existing APP_URL rewrite. Values can use , , and placeholders or be plain strings. When APP_URL is present in env_overrides it takes precedence over the default rewrite; declared keys override defaults and undeclared defaults still apply. Multi-tenant apps that depend on session domains, tenant routing, or signed-URL hosts no longer need a manual .env patch per worktree.
  • Automatic wildcard cert SANs and nginx server_name for worktree subdomains (#309). When a worktree is created on a secured site, lerd now includes *.branch.domain.test in both the TLS certificate SANs and the nginx server_name directive. Deep subdomains like app.branch.domain.test work out of the box without manual cert regeneration. The parent cert is reissued with worktree wildcard SANs on creation, and the daemon-startup sync reissues certs that picked up worktrees added before the feature shipped. New WorktreeCertDomains helper builds the domain list and ReissueCertForWorktree is exposed for the daemon sync flow.
  • Unified asset-worker / npm-build prompt for lerd worktree add (#321). The previous auto-skip-or-prompt branch is replaced by one select that lists every eligible asset worker (replaces_build + per_worktree + check passes), every npm production-build script (build / prod / build:prod / build-prod / production), and Skip. Default prefers an opted-in asset worker, then the first npm script, then skip. Asset workers appear even when not in the parent's .lerd.yaml workers: list; picking one starts it ad-hoc with persist=false so the parent yaml stays the source of truth and the choice is per-session.
  • Worktree-add modal's "Automatic" resolution now announces its choice (#344). applyWorktreeBuildRequest calls a new logAutoBuildResolution whenever the build request is "" or "auto", emitting one explicit line stating which branch the resolver took and why. The asset-worker case names the worker and explains the .lerd.yaml workers: opt-in plus replaces_build rule; the script case names the npm run target it's about to invoke; the skip case lists the production-build script names it looked for and warns about ViteManifestNotFoundException. ApplyWorktreeBuildChoice also gains a "skip" case so the modal never goes silent after the dependency-install phase, and CLI users hitting the huh "Skip" prompt see the same line. Users no longer have to read the buried per-kind log line and guess what "Automatic" picked.
  • Worktree-add warnings surface in the dashboard (#343). RunWorktreeAdd now wraps its log writer in a warningCapturingWriter that scans for [WARN] lines and returns them alongside branch and path. The SSE done payload forwards them; WorktreeModal keeps the log view open with an amber "completed with warnings" banner and a "back to list" button instead of silently jumping back to the list on a half-finished setup (db skipped, build script error, install timeout, etc.). Two new i18n keys added across de/en/es/fr/id/nl/pt.
  • Per-worktree dump tagging in the live viewer (#343). LERD_SITE and LERD_BRANCH are plumbed through nginx fastcgi_param (worktree and parent vhosts) and through podman exec --env from RunTinker. The dump bridge reads $_SERVER first, then getenv(), so both FPM and CLI (tinker) requests get the same identifiers. The dump payload gains a branch field across Go (internal/dumps/event.go), PHP, and the TypeScript stream type, so events from a worktree are no longer mis-grouped under the worktree's directory basename.
  • TUI service lifecycle keybinds (u update, b rollback). Pressing u while focused on the Services pane runs lerd service update <name> for the highlighted service (no tag, applies the safe in-strategy update). Pressing b runs lerd service rollback <name>. Both fire and refresh the snapshot, mirroring the dashboard's Update / Rollback buttons. No-op on worker rows (queue-alpha, etc.) since they have no upstream image. Help reference (?) lists both. Migrate, remove, and reinstall stay CLI/dashboard-only for now since they need tag pickers or destructive-action confirmation.
  • TUI header pill for failing workers. When any framework worker (built-in queue/schedule/horizon/reverb, custom worker, or per-worktree worker) is in the systemd failed state, the TUI header renders ⚠ N workers failing · H to heal in failing-style colour next to the existing DNS / nginx / FPM / watcher pills. Mirrors the dashboard's amber sticky banner from #319. Helper countFailingWorkers aggregates state across every site and worktree.
  • TUI per-worktree controls in the site detail pane. Each worktree row now expands to show its per-worktree workers (one row per FrameworkWorker in the worktree, e.g. vite on a Laravel branch), an "Isolated DB" toggle when the parent uses a managed database service (mysql / mariadb / postgres), a "LAN share" toggle, and PHP / Node version pickers. Pressing space/enter on a worktree-worker row runs lerd worker start/stop <name> from inside the worktree's checkout, so the worktree-suffixed unit (lerd-<worker>-<site>-<branch>) is targeted; the DB row runs lerd db:isolate / lerd db:share, the LAN row runs lerd lan share / lerd lan unshare, and the PHP / Node pickers run lerd isolate <ver> / lerd isolate:node <ver>, all with cwd inside the worktree so the choices persist there rather than at the parent. Version rows show "(inherited)" in dim style when the worktree hasn't overridden the parent. Help reference (?) gains a Worktrees section. New LANPort field on siteinfo.WorktreeInfo populated from config.FindWorktreeLAN.
  • MCP site_php / site_node gain a branch parameter. When set, the version pin is written inside the worktree's checkout and php_version / node_version is persisted to that worktree's .lerd.yaml (so the override travels with the branch in git); only the worktree's nginx vhost is regenerated. Without branch, the existing parent-site flow runs unchanged. Routes through lerd isolate / lerd isolate:node which already handle the worktree case correctly via FindParentSiteForWorktree.
  • MCP workers_mode tool. Wraps lerd workers mode <get|set> so AI assistants can flip the macOS worker runtime between exec (default; one podman exec per worker, supervised by launchd, lower memory) and container (one detached container per worker, 1:1 supervisor boundary). Linux always uses exec under systemd, so the tool is a no-op there. Setting the mode on macOS stops each active worker in its old shape and restarts it in the new shape, so no manual lerd stop / lerd start is needed.
  • MCP bug_report tool. Wraps lerd bug-report so agents helping users file GitHub issues can generate the diagnostic bundle (lerd doctor + config + systemd / podman state + recent logs + curated env vars) without leaving the chat. Site names, domains and parked-directory paths are anonymised by default; show_real_names: true keeps raw values for local debugging. Returns the file path so the user can attach it to the issue.
  • PHP 7.4 and 8.0 as a frozen legacy tier. Old projects (Laravel 6–8 on 7.4, Laravel 8–9 on 8.0) can now run under lerd. They build from the same Alpine-based recipe as the current versions, including ICU full locale data; the only version-conditional bit is the Xdebug release (pinned to 3.1.6 for 7.4, 3.3.2 for 8.0, since current Xdebug needs PHP 8.1+), and mongodb is unavailable on those lines. The base image is Alpine 3.16, so the bundled Node.js is 16.x. These are end-of-life upstream and get no security patches; they exist for local work on legacy apps only. lerd use 7.4, lerd isolate 8.0, lerd fetch 7.4 8.0 all work; both versions are in base-images.yml and SupportedPHPVersions.
  • lerd php:ext add --apk-deps "pkg ..." for extensions that need extra Alpine build packages (follow-up to #334, which already covers imap). Some PECL extensions need -dev packages that aren't in the base FPM image (ssh2 needs libssh2-dev, memcached needs libmemcached-dev, …). --apk-deps lets you supply them; they're saved per extension in ~/.config/lerd/config.yaml under php.ext_apk_deps, deduped with the built-in set, and reapplied on every lerd php:rebuild. lerd php:ext list shows them, and the MCP php_ext add action takes an apk_deps argument.
  • Dashboard now ships in German (de), Indonesian (id), and Dutch (nl), and dozens of previously-hardcoded screens are now translatable in every locale. The three new locales get a full set of translated strings, wired into the inlang project, the language switcher, and LOCALE_LABELS / LOCALE_CODES. As part of the same pass the Dumps viewer, the Tinker tab, the site Overview/detail tabs, the service detail + reinstall/remove modals, the worktree-DB-isolate modal, and a batch of smaller components/modals were migrated off hardcoded English into the m.*() catalog and translated across all seven locales (en/es/pt/fr/de/id/nl), so es/pt/fr benefit too. A vitest check (src/lib/locales.test.ts) guards every locale file against key drift from en.json. Native-speaker review/polish welcome, especially for id.

Changed

  • Upgraded the dashboard to Tailwind CSS v4 (#341). CSS-first config (@theme in app.css, no tailwind.config.js), the v4 PostCSS plugin (@tailwindcss/postcss, autoprefixer dropped), class-based dark mode preserved via @custom-variant, and the v3-compat border-color base layer. All utility renames applied across the component tree (outline-noneoutline-hidden, roundedrounded-sm, ringring-3, shadow-smshadow-xs, etc.).
  • config.SiteSlug consolidates DB-safe name conversion (#315). The repeated strings.ToLower + ReplaceAll("-","_") pattern across internal/cli/env.go::projectDBName, internal/cli/worktree_db.go::WorktreeDBName, internal/serviceops/reprovision.go::resolveDBName, and internal/mcp/server.go::db_set is now a single helper that also handles dots so domain names slugify through the same path. Pure refactor, no behaviour change.
  • MCP worker / worker_list tools gain a branch parameter and route through the lerd CLI. Previously the MCP worker tool inlined its own systemd unit content (no host: true support, hardcoded lerd-<worker>-<site> unit name) so calling worker(action: "start", worker: "vite") quietly wrote a non-functional unit and ignored worktrees entirely. The handlers now shell out to lerd worker start/stop with the cwd set to the parent site path or the resolved worktree path when branch is given, inheriting the CLI's worktree-aware naming, host execution, conflict handling, and persistence. worker_list reports the new host / per_worktree / replaces_build flags and accepts branch to switch between parent and per-worktree unit state. The injected SKILL / Junie guidelines / Cursor rules describe the new param so AI assistants drive per-worktree workers correctly.
  • Makefile UI deps gain a stamp file (#343). node_modules/.package-lock.json is now an explicit target depending on package-lock.json plus package.json, so npm ci runs only when the lockfile or package.json change instead of every make build / make test-ui.

Fixed

  • macOS phantom launchd workers restarted healthy workers every cooldown (#343). workerNeedsHealing and sweepOrphanWorkerArtifacts were looking for ~/Library/LaunchAgents/lerd.<unit>.plist, but the file on disk is <unit>.plist (the com.lerd. prefix lives only on the launchd Label inside the plist, see services.plistPath / plistLabel). The heal loop therefore saw every unit as "plist missing" and restarted healthy workers each cooldown; the orphan sweep simultaneously failed to keep real orphan .sh / .pid files. Both now read the correct path. isExecModeUnit gets the same fix in internal/tui/log_tail_darwin.go.
  • vite toggle and log tab vanished from parent site rows after the per-worktree worker pass (#346). The siteinfo guard skipped IsPerWorktree() workers from enrichWorkers unconditionally, removing the vite toggle and log tab from sites that still run the worker at the parent level (no worktrees, or worktree-using parents that want vite opted in for project-level control). The check rule on the worker (file: node_modules/vite for the Laravel store yaml) is already a tight enough gate, so the per_worktree skip was just removing correct rows along with the perma-inactive ones. The gate is gone; the test file now pins both directions.
  • lerd install (and lerd update, which runs install) briefly dropped .test resolution on every run. The installer unconditionally systemctl restarted lerd-dns, which tears down and recreates the podman container, a few-second window where port 5300 isn't bound and *.test won't resolve. It now diffs the dnsmasq config (lerd.conf) and the lerd-dns quadlet against what's already on disk and only restarts when one of them actually changed (or the container isn't running); a no-op reinstall, the common case after a version bump, leaves the live container alone. New fileChangedBy helper wraps the read-mutate-read diff. macOS (native dnsmasq, no container teardown) is unchanged.
  • Dashboard "Open terminal & update" button failed with lerd: command not found. The handler shelled out via sh -c "lerd update; …", but the spawned terminal inherits a non-login shell environment that doesn't source .bashrc / .zshrc, so ~/.local/bin is off PATH. The script now uses os.Executable() to resolve the absolute path of the running lerd-ui binary (which is the lerd binary itself) before passing it to the shell. Same fix pattern the TUI's runLerd already uses.
  • Dashboard update banner showed Lerd vv1.19.2 is available. Two bugs compounded: the wire payload from /api/version returned the GitHub tag verbatim (with leading v) while the Svelte template Lerd v{version} is available already prepends v, producing the double-v. handleVersion now strips the v via lerdUpdate.StripV before sending so the wire data is bare and the template renders cleanly.
  • internal/update test pollution rewrote the user's real update-check.json. withTempCache set XDG_CONFIG_HOME but config.UpdateCheckFile reads XDG_DATA_HOME, so test cache writes leaked to ~/.local/share/lerd/update-check.json and surfaced bogus version tags (e.g. v1.19.2) in the dashboard banner for 24h. Test now sets the correct env var so writes stay in the temp dir.
  • Stable users got "update available" prompts when a beta was published. GitHub's /releases/latest redirect normally excludes prereleases, but a brief redirect-cache window (or a release accidentally not marked prerelease) can land a beta tag in cachedLatest(), which CachedUpdateCheck then surfaces to every stable user. CachedUpdateCheck now skips the notification when the cached latest carries a prerelease suffix and the current version doesn't. Beta-on-beta upgrades still surface, and stable-to-stable is untouched. New IsPrerelease helper plus four pinned tests cover the matrix.
  • lerd framework update only refreshed the latest cached version (#318). The command iterated ListFrameworksDetailed (one entry per <name>@<version>) but always fetched entry.Latest from the store. A user with laravel@10/11/12/13 cached only got laravel@13 refreshed; older cached versions stayed stale until the 24h auto-refresh ran or lerd install triggered a fetch. The loop now fetches info.Version so each cached file is refreshed individually.
  • lerd secure / lerd unsecure left VITE_REVERB_* stale, breaking Reverb WebSocket on secured sites. Toggling HTTPS only updated APP_URL via envfile.UpdateAppURL. VITE_REVERB_HOST/SCHEME/PORT kept their pre-flip values, so a site secured after lerd env ended up with VITE_REVERB_SCHEME=http / VITE_REVERB_PORT=80 while the browser was on HTTPS. Vite bakes those into the built JS, so Echo tried wss://host:80 (TLS WebSocket on the HTTP port) and the connection failed silently every page load. The same omission affected the dashboard's secure/unsecure handlers and the per-worktree paths inside SecureSite / UnsecureSite. All five call sites now use envfile.SyncPrimaryDomain, which has been the canonical helper for this since #209 (April 10) and was already used by lerd domain add/remove and TLD migration. TestUpdateEnvAppURL_syncsViteReverbVars and TestUpdateEnvAppURL_unsecureWalksViteReverbBack pin the regression. The bug predates SyncPrimaryDomain itself (introduced in v0.3.0, 2026-03-18), so existing secured Laravel sites with BROADCAST_CONNECTION=reverb need to rebuild Vite assets after this update.
  • Worktree wildcard cert SANs only refreshed on per-worktree sync, not on bulk regen. syncWorktree reissued the cert when a single worktree was added, but scanWorktrees (daemon-startup discovery) and cleanupWorktreeVhosts (post-cleanup regen) wrote *.branch.domain.test into the nginx server_name without reissuing the cert, so deep subdomains like app.branch.domain.test failed TLS after a daemon restart that picked up worktrees added before the wildcard-SAN feature shipped. Both bulk paths now reissue once per site; cleanup also shrinks the cert SAN list when worktrees are removed.
  • A transient mkcert failure during cert reissue could downgrade a secured site to plain HTTP. issueCertWithWorktrees removed the existing .crt and .key files before invoking mkcert so the new SANs would take effect; if mkcert hiccuped (binary missing after an update, permission drift, anything), the site was left with no cert files and the next start tripped RepairVhosts into flipping Secured=false. The reissue path now writes mkcert output to *.new temp paths and renames atomically, so a failure leaves the previous cert intact. New IssueCertForce exposes the atomic flow and TestIssueCertForce_failureLeavesExistingCertIntact regression-pins the safety property by simulating a failing mkcert binary.
  • Dashboard DNS dot stayed red after boot until manual refresh. When lerd-ui came up before lerd-dns finished starting, the initial /api/status snapshot baked in dns.ok=false. Once DNS naturally became reachable a few seconds later nothing pushed an update to the live WebSocket, so the DNS pill on the dashboard stayed red until the user reloaded. The first attempted fix added the publish to WatchDNS in the watcher binary, but lerd watch and lerd serve-ui are separate processes and eventbus.Default is per-process, so that publish never reached the broker. The actual fix lives in internal/ui/dns_status_watcher.go: a small in-process probe runs inside lerd-ui, ticks every 30s while a tab is visible, compares against the last observation, and publishes eventbus.KindStatus on transition. runSnapshotInvalidator then rebuilds the status snapshot and the broker pushes it to every tab. Cost is one local net.LookupHost per 30s when the dashboard is open, zero work when no tab is open. The WatchDNS immediate-probe-at-startup change still applies and shaves 30s off the first repair tick. Table-driven tests cover the boot-up-publish, boot-down, steady-up, steady-down, recovery, regression, and idle-skip paths.

[1.19.3] — 2026-05-11

A small patch release: lerd php:ext add no longer reports success when the extension's build actually failed.

Fixed

  • lerd php:ext add <ext> reported success even when the PECL build failed (#334). The custom-extension RUN block ends in || true so a broken build doesn't brick the whole image on later lerd php:rebuild runs, but that also meant lerd php:ext add imap printed Extension "imap" installed while php -m showed nothing. lerd php:ext add (and the MCP php_ext add action) now run php -m in the rebuilt image to confirm the extension loaded; if it didn't, the command exits with an error and removes the extension from ~/.config/lerd/config.yaml again instead of leaving a phantom entry.
  • Some extensions failed to build because their Alpine build deps weren't present (#334). imap in particular needs imap-dev krb5-dev openssl-dev c-client or PECL aborts with utf8_mime2text() has new signature, but U8T_CANONICAL is missing. lerd now apk adds the required packages before pecl install for extensions it knows about (currently imap); the map is easy to extend for others.

[1.19.2] — 2026-05-11

A one-fix patch release: locale-aware formatting now works inside the PHP-FPM containers.

Fixed

  • NumberFormatter / locale-aware currency and number formatting produced English output (€ 13,943.20 instead of € 13.943,20) inside PHP-FPM containers (#332). The Alpine PHP image only bundled icu-data-en (Alpine splits ICU's locale database into a separate package), so ext-intl silently fell back to the root/en locale for every non-English locale, breaking NumberFormatter('nl_NL', …), IntlDateFormatter, Laravel's Number::currency() and money formatting, and any test that asserts localised output. The Containerfile now installs icu-data-full, which carries the full CLDR locale set. This bumps the Containerfile hash so existing images rebuild on next use (or lerd php:rebuild). Note this does not change the C-library setlocale() / localeconv() path: musl libc does not implement non-C LC_NUMERIC / LC_MONETARY, so apps relying on locale-aware formatting should use ext-intl (NumberFormatter), which is locale-data driven and unaffected by the system locale.

[1.19.1] — 2026-05-07

A maintenance release rolling up the post-v1.19.0 fix queue: Podman 4.x compatibility on Ubuntu 24.04, MySQL 8.4 client + driver compatibility for Laravel sites, broader system-node detection so users on nvm/volta/mise/asdf/fnm aren't quietly shimmed, host-port conflict surfacing on stopped services, and a .lerd.yaml honour fix on Laravel lerd init.

Fixed

  • Service quadlets failed to generate on Podman 4.x (#299, #302). v1.19.0 emitted StopTimeout=5 in every service's [Container] section, but that key was added in Podman 5.0 and is unrecognised by the 4.9.3 shipped on Ubuntu 24.04. systemd-quadlet aborted with exit 1 and produced no service units, so lerd-mysql.service, lerd-redis.service etc. simply didn't exist. Lerd now probes podman --version once and falls back to PodmanArgs=--stop-timeout=5 on Podman <5.0, which is universally supported and produces the same --stop-timeout=5 on the underlying podman run.
  • lerd install failed creating the lerd podman network on Ubuntu 24.04 (#299, #304). After creating the bridge, lerd ran podman network update --dns-add <ip> lerd for each upstream DNS server (e.g. libvirt's 192.168.122.1), but on Ubuntu Noble's netavark <1.11 that command needs the per-network aardvark-dns runtime file to already exist, which it doesn't until a container has connected. The result was a hard install failure ending in netavark: unable to modify network dns servers: IO error: No such file or directory. Lerd now passes --dns <ip> flags at podman network create time, so the DNS servers are written into the netavark JSON in the same atomic step as the subnet and MTU. The post-create drift sync still runs but is now a no-op on a fresh install. --no-ipv6 is no longer needed as a workaround.
  • Dashboard could go green for a service with no unit (#299, #301, #302). When a quadlet generator rejected a .container file but a container from an earlier valid load was still in the cgroup, systemctl --user list-units printed not-found active running and the dashboard read the active column verbatim. The unit-state cache now collapses any LOAD other than loaded (not-found, masked, bad-setting, error) to inactive so the UI matches reality.
  • MySQL 8.4 was unusable from Laravel CLI tools (#303). The PHP-FPM container ships Alpine's mysql-client (which is the MariaDB client), and MySQL 8.4 broke it on two fronts. First, MySQL 8.4 auto-generates self-signed TLS certs that the MariaDB client refuses to validate, breaking every mysql / mysqldump invocation Laravel and packages like spatie/db-dumper shell out to. Second, MySQL 8.4 disables mysql_native_password by default and the MariaDB client doesn't speak caching_sha2_password, so the CLI couldn't authenticate even with TLS off. Lerd now ships /etc/my.cnf.d/lerd-no-ssl.cnf inside the PHP-FPM image (full and fast-path builds) so the MariaDB client connects in plaintext over the trusted lerd network, and the MySQL preset config adds loose-mysql_native_password=ON so the auth plugin is loaded on startup. The loose- prefix keeps this config compatible across MySQL 5.6 / 5.7 / 8.0 / 8.4. Also disabled restrict_fk_on_non_standard_key (new in 8.4, ON by default) which was rejecting existing dumps with foreign keys against non-unique columns, and added ffmpeg to the PHP-FPM image for media-library packages that depend on it.
  • lerd init on a fresh Laravel project ignored the .lerd.yaml DB pick (#305). Picking MySQL or Postgres at init time saved the choice to .lerd.yaml but never updated .env, so Laravel kept DB_CONNECTION=sqlite and the user's selection silently dropped on the floor. Laravel defines Env.Services, so runEnv took the framework path which only applied services it could detect already in the .env; the non-framework branch already honoured .lerd.yaml. The yaml-honour decision is now extracted into shouldApplyService / userPickedDBFromYAML helpers and reused on both paths, with table tests covering the regression case (yaml says mysql, env doesn't have it, still apply mysql).
  • Stopped services with port conflicts had no visible cause (#300). When a service unit was installed but stopped and another process held its host port (a system-installed Postgres on 5432, a stray Docker container, etc.), Start failed with a generic bind error and the user couldn't tell why. Lerd now surfaces the conflict passively in three places before they click anything: lerd doctor gains a [Stopped service ports] section that walks installed-but-inactive services and warns per bound port; /api/services snapshots include port_conflicts so the dashboard renders an amber pill on the service detail and a small alert icon in the sidebar list; and the port-finding hints in lerd start pre-flight, doctor, dns:diagnose, and the docs now branch on GOOS so macOS users get an lsof command instead of the Linux-only ss -tlnp string. The cli port helpers (PortInUse, PortInUseIn, PortListOutput, PortCheck, CollectPortChecks) are now exported so internal/ui can reuse them without duplicating the ss-vs-lsof logic.
  • Lerd silently shimmed Node on hosts using nvm / volta / mise / asdf / fnm (#297). detectSystemNode only probed node in PATH, so a developer with their own version manager (whose shim is loaded later in shell rc) wouldn't see the new "Let lerd manage Node.js?" prompt and would end up with ~/.local/share/lerd/bin/{node,npm,npx} masking their tooling. Detection now also probes npm / npx and the well-known version-manager dirs, so those users get prompted instead. After confirming managed Node, install also runs fnm install + fnm default so the user is left with a working npm (fixes the "can't find version: default" surprise on first run); the shim now probes the default alias before exec to catch the "versions installed but no default" case alongside an empty list. lerd node:install / node:use / node:uninstall warn and require confirmation when lerd isn't currently managing Node, and write fresh shims on accept so CLI opt-in matches the install flow. The dashboard NodePage install input/button is disabled when Node is system-managed, with a hint pointing at lerd install, and the UI endpoints have matching server-side guards.
  • Opting out of lerd-managed Node left fnm shims masking system node (#306). lerd install adds a "Let lerd manage Node.js?" prompt when a system node is detected; answering no on a host that previously had managed node skipped writing fresh fnm shims but didn't remove the existing ones, so ~/.local/share/lerd/bin/node|npm|npx kept overriding system node in PATH. The opt-out path now deletes the three shims so the user's choice actually takes effect.
  • Podman version probe missed Homebrew under launchd (#306, macOS). The StopTimeout= vs PodmanArgs=--stop-timeout=5 selector ran exec.Command("podman", "--version"), which fails under launchd's restricted PATH where podman is at /opt/homebrew/bin/podman. The probe now goes through PodmanBin() so launchd-spawned lerd-ui picks the modern key on Podman 5.x instead of always falling back.

[1.19.0] — 2026-05-04

A large feature release. Highlights: a brand-new Svelte 5 dashboard with a command palette, every default service moved to YAML presets, a first-class update / migrate / rollback / reinstall flow with linked-site reprovisioning, the in-browser Tinker REPL, optional disabled-DNS install mode, worker self-heal, full git-worktree support, and macOS parity for the Linux-only pieces of the v1.19 surface.

Added

Services and data lifecycle

  • YAML-driven default service presets. The six built-in services (mysql, postgres, redis, meilisearch, rustfs, mailpit) moved out of hardcoded Go lists and embedded .container templates into internal/config/presets/*.yaml files marked default: true. Adding or replacing a default service is now a YAML edit; default and add-on presets share one quadlet writer, one env-var resolver, and one dependency engine. Six duplicated service-name lists collapsed into config.DefaultPresetNames(); three duplicated env-var maps collapsed into config.DefaultPresetEnvVars(name).
  • MySQL canonical bumped to 8.4 LTS (was 8.0). Existing users on saved 8.0 are untouched (viper merge wins; migrateStaleServiceImages skipped for track_latest presets); fresh installs land on 8.4.x. The 5.6 alternate is gone; 5.7 and 8.0 remain pickable. mysql.cnf loose- prefixes added so the same config file works across mysql 5.6 / 5.7 / 8.0 / 8.4 (8.4 hard-rejects innodb_large_prefix / innodb_file_format without the prefix).
  • Update, upgrade, migrate, and rollback flow. New serviceops.UpdateServiceStreaming, serviceops.MigrateService, serviceops.RollbackService, plus an internal/registry package that queries Docker Hub and GHCR for newer tags. Per-preset update_strategy (patch / minor / rolling / none), track_latest (fresh installs resolve current upstream), and allow_major_upgrade (gates cross-major NewestStable).
    • CLI: lerd service update <name> [tag], lerd service migrate <name> <tag>, lerd service rollback <name>. lerd service list gains an Update column with green and amber badges showing pending updates.
    • Web UI: green Update, amber Upgrade, violet Migrate (mysql / postgres / mariadb only), grey Rollback buttons in the service detail panel. Streaming NDJSON phase events into the in-flight UI machinery.
    • MCP: service_check_updates for read-only status; service_control action enum widened to update, migrate, rollback, restart, remove, reinstall. service_remove and service_update removed as standalone tools.
  • Remove and reinstall any service, including default presets (#294). lerd service remove <name> [--purge] and a new lerd service reinstall <name> [--reset-data] work on default presets too. --purge and --reset-data rename the data dir aside (<dir>.pre-remove-<unix-nanos>) so the wipe stays recoverable. With --reset-data, reinstall auto-reprovisions per-site state on the fresh container: CREATE DATABASE for mysql/mariadb/postgres (resolving names via .lerd.yaml db.database, then .env DB_DATABASE, then a derived site name), mc mb for rustfs (using .env AWS_BUCKET or the derived site name). New web UI modals for both flows; default-service deletes that affect linked sites require typing the service name to confirm.
  • Migration safety guards. Rolling-tag updates suppressed when local manifest digest matches remote (no phantom badges on :latest). Cross-major upgrades hidden from the Upgrade button by default; opt-in via per-preset allow_major_upgrade. Cross-strategy upgrade button suppressed for update_strategy: patch presets without a registered SQL migrator (Meilisearch, where clicking would brick the data dir). Alternates installed via preset (e.g. mysql-8-0) internally promote to patch strategy so they don't get auto-suggested cross-LTS jumps.
  • internal/registry package. ListTags, MaybeNewerTag, NewestStable against Docker Hub and GHCR with a 6-hour disk cache. Typed *UnreachableErr and *UnsupportedRegistryErr are swallowed by the high-level helpers so offline or unsupported-registry installs stay quiet rather than spamming errors.
  • /api/services/<name>/{updates,update,rollback,migrate,reinstall}. Read JSON plus streaming NDJSON endpoints mirroring the existing preset-install streaming flow.
  • Gotenberg API preset for PDF generation and document conversion (#268, #271).

Web UI and developer surfaces

  • Dashboard rewritten in Svelte 5 + TypeScript (#260). The previous 4,800-line Alpine.js monolith (internal/ui/index.html) is replaced by small composable components per tab, stores, and modals under internal/ui/web/. Every feature from the Alpine version is preserved; bundle ships as a single hashed JS + CSS file embedded in the Go binary (~60 KB gzipped JS, 7 KB gzipped CSS). Vite build runs before go build via make build-ui. No backend or API changes; the WebSocket snapshot protocol and /api/* routes are identical.
  • Dashboard root with live widgets and global command palette (#280). Replaces the empty "select a site" landing page with a Dashboard tab built around at-a-glance widgets backed by the existing WebSocket pipeline. Eight focused cards in a 3-col grid: HeroStatus alert strip, Sites list, Services bounded grid with update banner, Workers per-group active counts, System Health (PHP-FPM and Node usage), Resources (CPU + memory via a new /api/stats endpoint that parses podman stats --no-stream), Lerd info merged with a Recent activity timeline driven by a frontend diff over WS snapshots, and a dismissible Onboarding panel when no sites are linked. Plus a global command palette (Cmd+K, Ctrl+K, or /) for jumping to any site, service, page, or action from any tab.
  • Tinker tab, in-browser PHP REPL per site (#282). CodeMirror-backed editor with context-aware autocomplete (project models from PSR-4, composer helpers, PHP built-ins cached per version, framework hints, buffer variable completion), live php -l linting (debounced 2.5s, symbol cache per domain to keep lerd-ui CPU flat under heavy typing), and an editor-styled output panel with collapsible Symfony VarDumper trees. Driven by the framework definition's tinker: block (command, execute_flag, execute_positional, requires_package, requires_file); Laravel ships with one bundled, sites without a framework REPL run a temp script under plain php with composer autoload included. Bare expressions auto-dump per statement.
  • lerd bug-report command for GitHub issue triage (#266). Collects logs only for lerd's own infra units (lerd-nginx, lerd-ui, lerd-watcher, lerd-dns, lerd-tray, lerd-autostart, lerd-fpm-init); preset services keep their state in the unit-state and container tables but the noisy logs no longer ride along. Anonymizes site names, domains, parked-directory paths, and the username by default (site-1, site1.<tld>, $PARK_1, $USER); pass --show-real-names to keep raw values for local debugging.

Worker management

  • Worker self-heal across CLI, dashboard, TUI, and MCP (#279). New lerd worker heal [name] resets the failed state and restarts every worker unit systemd lists as failed (or one named unit). Same recovery is reachable from an amber sticky banner in the dashboard, the H keybind in the TUI, and the workers_heal / workers_health MCP tools. lerd status surfaces a one-line hint when any worker is in failed state. Heal is intentionally narrow: it never writes .lerd.yaml or rewrites the unit file. The dashboard banner is push-driven over WebSocket via a 5-second cached-detector watcher that publishes only when the unhealthy set changes.
  • lerd workers mode <exec|container> on macOS (#218). Framework workers (queue, schedule, horizon, reverb, custom) run via a pid-file-guarded podman exec into the shared FPM container by default (exec mode), matching the memory profile of the Linux systemd path. Each worker adds near-zero RAM compared to the previous per-worker-container model. Users who prefer the previous 1:1 supervisor boundary can opt back in with lerd workers mode container. Setting is also surfaced in the TUI settings overlay (S) and via GET/POST /api/settings/worker-mode in lerd-ui. The dedup guard prevents duplicate workers when the podman-machine SSH bridge hiccups: if a previous podman exec is still alive, launchd's relaunch exits cleanly. No change on Linux.

Networking and DNS

  • Optional disabled-DNS install mode for users who don't want lerd touching system DNS. lerd install asks Let lerd manage DNS for local sites (No: use *.localhost, no dnsmasq, no HTTPS)? after the Node prompt and persists the answer under dns.enabled. When disabled, the installer skips lerd-dns, dnsmasq config, the sudoers rule, the mkcert root CA install, and the resolver tweak. lerd start no longer manages lerd-dns, lerd doctor reports DNS as managed externally, and the dashboard hides per-site HTTPS toggles. HTTPS is intentionally coupled because mkcert never installed a trusted CA in this mode. Re-running lerd install with the opposite answer detects the TLD change, lists affected sites, and offers a one-pass migration (registry, .lerd.yaml, project .env, git-worktree vhosts and per-worktree .env, stale primary vhost confs, TLS cert/key on disable). Suggested by #281.
  • Layered DNS diagnostic in lerd doctor and an dns_diagnose MCP tool. Walks the chain end to end (lerd-dns container, dnsmasq config file, port 5300 listening, direct dig at 127.0.0.1:5300, resolver hookup script or drop-in installed, interface routing in resolvectl status, system-wide DNS lookup) and surfaces exactly which rung is broken with a one-line remediation hint per failure. Replaces the old single "not resolving to 127.0.0.1" error that conflated seven possible causes. The MCP variant returns the structured walk as JSON (steps[].status, first_failure index) so AI assistants can drive troubleshooting without scraping doctor's output. Inspired by #285.

Git worktrees

  • Worktree branch renames are picked up automatically (#264). The watcher monitors each .git/worktrees/<name>/HEAD for writes, so a git branch -m or git checkout -b inside a worktree re-syncs the nginx vhost and .env APP_URL to the renamed branch without a manual restart. Stale subdomain vhosts are removed surgically (only the now-stale one), not by regenerating every vhost on the site. Thanks to @ropi-bc for the contribution.
  • APP_URL realigns to the worktree domain on every scan (#263). Previously EnsureWorktreeDeps only rewrote APP_URL when it first created the .env; renames and external workflows that relied on a manual git branch -m left it pointing at the stale subdomain. The worktree scan now updates APP_URL on existing .env files too. Thanks to @ropi-bc for the contribution.
  • lerd worktree add and lerd worktree remove interactive wrappers mirroring git worktree's subcommand layout, with every flag passing straight through to git. add polls until the watcher's install pipeline (composer + npm) finishes, then prompts for the production-build script (skip / build / prod / build:prod / build-prod / production, whichever exist in package.json), and for the database setup (share parent / isolated empty / clone from main / clone from another isolated worktree). When the worktree's branch already has a preserved isolated DB the prompt prepends two extra options at the top: Reuse preserved isolated DB (reconnects without touching the schema) and Reset preserved DB to a fresh empty schema. Picking "isolated empty" then offers to run php artisan migrate --force. remove runs git worktree remove, recovers from "modified or untracked files" by offering a force-retry select prompt, then asks at the end whether to drop the worktree's isolated database (default Keep, so re-adds reconnect to the same data).
  • Opt-in per-worktree database isolation in the dashboard. The site controls expose an Isolated DB toggle whenever a worktree is active and the parent uses a lerd-managed mysql, mariadb, or postgres service. Flipping it on creates <parent_db>_<sanitized_branch> in the same service container, rewrites DB_DATABASE in the worktree's .env, and persists the choice as db_isolated: true in the worktree's .lerd.yaml (so it travels with the branch in git). On enable, lerd asks how the new schema should be seeded: empty, cloned from the parent (mysqldump --single-transaction | mysql or pg_dump | psql end-to-end inside the service container), or cloned from another already-isolated worktree. Cleanup ordering is vhost first, LAN-share second, isolated database last.
  • Per-worktree LAN-share proxy with a per-user registry at ~/.local/share/lerd/worktree-lan-shares.yaml. The dashboard's LAN toggle is worktree-aware; LANShareStartWorktree / LANShareStopWorktree allocate ports across the same pool the parent uses, the proxy targets <branch>.<parent>.test, and the QR-code popover passes ?branch= so the encoded URL is the worktree's. Cleanup on git worktree remove POSTs lan:unshare?branch= to lerd-ui so the listener closes in the daemon's process.
  • Worktree-scoped site detail view in the dashboard, with per-worktree PHP and Node versions. The path line carries an inline branch picker (/path · git:(main) 3 ▾) that collapses to a single line regardless of worktree count and opens a dropdown listing the main checkout and every active worktree with its derived domain. Picking a branch re-scopes the rest of the panel to that worktree: the site title and the Open / Terminal buttons target the worktree's domain and checkout path, the App logs tab tails storage/logs from the worktree's directory (/api/app-logs/{domain}?branch=<sanitized>), and the PHP / Node version selectors show the worktree's effective version with a dashed violet border when inherited from the parent. Changing a selector while a worktree is active writes a worktree-only override to .lerd.yaml inside the worktree's checkout, so the choice travels with the branch in git.
  • Worktree inheritance for lerd php and lerd node: php.DetectVersion and node.DetectVersion consult a path-only config.ParentSiteForWorktreeDir lookup after .php-version / .node-version and before composer.json / package.json constraints, so a worktree without an explicit override inherits the parent's pinned version instead of the highest installed satisfier. Composer's ^8.2 no longer silently picks PHP 8.5 on a worktree of a parent pinned to 8.4.
  • Watcher race fix for fresh worktrees: handleNewEntry polls HEAD until it's a final ref or SHA before firing onAdded, closing a window where fsnotify Created the entry dir before git finalised HEAD. The daemon's startup scanWorktrees pass also reconciles stale *.<site>.test.conf files now, so a worktree removed while the watcher was offline gets its orphan vhost dropped on next start.

Changed

  • Web UI cache poll backs off when the desktop session is idle or locked (#255). The podman ps cache that drives the dashboard already drops from 15s to 60s when no tab is visible. It now also drops to 60s while at least one tab is visible but systemd-logind reports the session as idle or locked, so a focused tab on an unattended laptop stops paying the per-15s subprocess cost. Recomputes on every transition via a 30s logind poll. Linux only; macOS keeps the visibility-only behavior via the existing SessionIsIdleOrLocked stub.
  • lerd service restart refreshes the quadlet before restarting, same path as start, so config edits and preset file mounts (mysql lerd.cnf) land on disk before the unit picks them up. Previously a stale quadlet from an earlier release could keep running until an explicit stop+start.
  • applyServices in the Web UI refreshes every server-supplied field on each WS push (was only status / pinned / site_count), so update / upgrade / version state propagates over the socket without a page reload. Client-only flags (loading, error, flash) still survive across pushes for in-flight UI state.
  • internal/podman/quadlets/lerd-{mysql,redis,postgres,meilisearch,rustfs,mailpit}.container deleted; quadlets are now generated from preset YAML on demand. internal/cli/service_image_{darwin,linux}.go deleted; platform image overrides moved into Preset.PlatformOverrides with optional template substitution so track_latest-resolved tags survive the platform swap.

Fixed

Service lifecycle (the new update / migrate / rollback flow)

  • Concurrent update / migrate / rollback now serialised per service (internal/serviceops/locks.go). Double-clicking the Update button, or a CLI run racing the dashboard, can no longer interleave config writes, image pulls, and data-dir swaps.
  • persistImageChoice and swapImagePin are atomic with quadlet regeneration. If the on-disk quadlet write fails after the config write, the config is rolled back so ~/.config/lerd/config.yaml and the generated .container file can't disagree. Joined-error output if the rollback itself fails.
  • Migrate failures restore the pre-migrate data dir. A new abortMigrate helper bubbles errors from restoreDataDirFromBackup and restarts the old unit when appropriate.
  • Rollback after a Migrate is refused. New LastOp and PreMigrateBackup fields on ServiceConfig / CustomService. RollbackService errors out when the last op was a migrate (running the old binary against the upgraded data dir would corrupt it). The dashboard hides the Rollback button via a new can_rollback field on /api/services.
  • SQL dump credentials no longer leak through argv. mysqldump and pg_dumpall receive MYSQL_PWD / PGPASSWORD via podman exec --env, not on the command line. Dump files now open with mode 0600; the backups directory is 0700.
  • 30-minute timeout on every in-container migrate exec. A wedged container can no longer block migrate forever; containerExec / dumpToHost / restoreFromHost use exec.CommandContext with a hard cap.
  • allow_major_upgrade is enforced even when the CLI passes a tag explicitly. A new enforceMajorUpgradeGate check refuses lerd service update mysql 9.0 for presets where allow_major_upgrade: false. The registry-recommendation gate could previously be bypassed by direct tag invocation.
  • Server-side NDJSON streams stop on client disconnect. A new startNDJSONStream helper short-circuits writes once r.Context() is cancelled or w.Write returns an error, replacing four copies of the closure in update / migrate / rollback / preset-install handlers.
  • Client surfaces stream errors instead of freezing the spinner. streamServiceAction calls setProgress with phase error before continuing, so a failed update shows the message in the UI instead of leaving the inline status stuck on the last visible phase.
  • Update-button gate uses strict equality. migration_supported === false / === true and can_rollback !== false so a missing field doesn't accidentally show the wrong button. JSON tags lose omitempty on those bool fields so the false case is always wire-visible.
  • lerd service restart warns instead of failing when quadlet regeneration trips. A transient template error no longer strands a healthy unit; the restart proceeds against the existing on-disk quadlet.
  • lerd update no longer silently bumps preset minors past the user's installed version. EnsureDefaultPresetQuadlet reads the existing on-disk image when update_strategy is patch / minor / none and the user has no explicit pin, so lerd update (which calls install --from-update) keeps users on their current minor instead of replacing it with whatever the new preset declared. Meilisearch v1.7 to v1.42 used to swap engines in one step and hard-fail because the older data dir won't load under a newer binary. Rolling-strategy services (mailpit, rustfs, gotenberg) still pick up the preset image as before via the existing track_latest block.

Registry and update detection

  • Docker Hub pagination followed. dockerHubResponse.Next was previously read but never followed; long-tail repos (postgres, mysql) lost newer tags past page 1. Capped at 20 pages and 5000 tags so a pathological response can't drive unbounded traffic.
  • Distinct error classes. New *AuthRequiredErr and *NotFoundErr. 401/403 means auth needed, 404 means repo doesn't exist, 429/5xx means unreachable. All four collapse to "no update info" via isQuietRegistryErr but stay distinguishable.
  • Token and tag-list calls have separate timeout budgets (15s each). A slow GHCR token endpoint can no longer starve the subsequent tag-list request.
  • Concurrent cache writes safe. In-process cacheMu plus an inline singleflight collapses concurrent ListTags calls; cache writes go to a temp file and os.Rename atomically. Cache write failures emit a rate-limited log.Printf instead of disappearing into a discarded error.
  • Response body capped at 5 MB and tag list at 5000 entries so a malicious or misconfigured registry can't OOM the process.
  • Digest comparisons normalised (lowercase + trim) in the alreadyOnDigest helper so registries returning differently-cased digests don't cause a permanent "update available" badge.

Watcher and worktree handling

  • cleanupWorktreeVhosts no longer triggers composer install and the JS install on every surviving worktree. When one worktree was removed, the cleanup pass that re-generated vhosts for the survivors also called EnsureWorktreeDeps on each one, which kicked off a full composer install plus the JS package-manager install. The rename and add paths handle deps via syncWorktree; doing it from cleanup burned cycles for no benefit.
  • .env APP_URL rewrite is skipped when the value is already correct. rewriteAppURL compares the new bytes against the file before writing, so a no-op worktree scan no longer bumps .env's mtime. Dev-side watchers (vite, IDE indexers, opcache file_update_protection) used to react to every scan even when the URL hadn't changed.
  • lerd-watcher no longer re-runs composer install and the JS package manager on every restart. scanWorktrees invokes EnsureWorktreeDeps for each worktree on startup, which previously called InstallDependencies unconditionally; composer's "Nothing to install" path still fires the post-autoload-dump scripts (Laravel package:discover, Filament asset publish, etc.), accumulating several seconds of churn per worktree per restart. InstallDependencies now consults each manager's install marker (vendor/composer/installed.json for composer; .modules.yaml / .package-lock.json / .yarn/install-state.gz / .bun-tag depending on the JS lockfile) and skips when the marker is at-or-newer than the lockfile.
  • go test ./... no longer rewrites the user's PHP-FPM quadlets and daemon-reloads systemd, which used to cascade workers into start-limit-hit. internal/php/versions.go::ListInstalled() had a self-heal step that, when called from a test with XDG_CONFIG_HOME redirected to a temp dir, looked at the user's real running FPM containers, decided their quadlets were "missing", and wrote fresh quadlets plus ran systemd --user daemon-reload against the real session. Each reload triggered a quadlet-generator pass which restarted the FPM unit; with workers BindsTo=lerd-php8X-fpm, that cascade rapidly tripped StartLimitBurst and parked every worker in failed. ListInstalled() is now read-only.

macOS parity

  • macOS parity for v1.19 features that were Linux-only. Worker self-heal, the dashboard's failed-worker banner, and the workers_health MCP tool see real failed-unit state on macOS now. siteinfo.AllUnitStates was returning an empty map on darwin, so workerheal.Detect never fired. The UnitLifecycle interface gained AllUnitStates(), the darwinServiceManager walks ~/Library/LaunchAgents/lerd-*.plist to populate it, and siteinfo/unitcache_darwin.go plugs the launchd walker in via a new allUnitStatesFn hook. The "last error" excerpt for failed workers also works on macOS now via a platform-split readLastError that tails ~/Library/Logs/lerd/<unit>.log instead of relying on journalctl.
  • lerd lan:expose no longer fails on macOS when DNS is enabled. The DNS-forwarder install path called systemctl directly to write the unit, enable it, and daemon-reload, every one of those returned ENOENT on darwin. Routed through services.Mgr.WriteServiceUnit / Enable / DaemonReload / RemoveServiceUnit so the same systemd-format unit content renders to a launchd plist on macOS via parseServiceUnit, and the Linux path is unchanged.
  • Worktree auto-install no longer skips pnpm / yarn / bun on Apple Silicon. The lerd-watcher daemon inherits launchd's restricted PATH (/usr/bin:/bin:/usr/sbin:/sbin), so the bare exec.LookPath("pnpm") returned ENOENT and the install step failed for any non-npm project. New lookJSBin helper falls back to /opt/homebrew/bin and /usr/local/bin on darwin, mirroring podman.PodmanBin().
  • Container resource stats on macOS. The dashboard's Resources card was empty on darwin because the bare exec.Command("podman", ...) couldn't find podman under launchd's restricted PATH. Now uses podman.PodmanBin() like the rest of the project.
  • Tinker tab works on Laravel projects with non-trivial vendor trees. PHP CLI's 128 M memory_limit default was exhausted by ClassAliasAutoloader during boot (it requires the full composer class map upfront). Tinker's podman exec now passes -d memory_limit=512M. Also sets PSYSH_TRUST_PROJECT=1 to silence PsySH's non-interactive "Restricted Mode" warning that otherwise broke simple expressions like echo env('APP_URL');.
  • PostGIS preset on Apple Silicon uses the upstream image instead of the imresamu fork. The official postgis/postgis manifest publishes only amd64 entries; on darwin a new podman.PlatformPodmanArgs hook injects PodmanArgs=--platform=linux/amd64 into the rendered quadlet so podman pulls the amd64 manifest and Podman Machine runs the container under qemu user-mode (or Rosetta when the VM has it wired). Hooked centrally at WriteQuadletDiff so cli, UI, MCP, and install all emit byte-identical units. Existing data dirs initialised under the imresamu fork open cleanly on the upstream image; users may want to run ALTER EXTENSION postgis UPDATE once after the swap. Pinning a multi-arch fork explicitly in ~/.config/lerd/config.yaml bypasses the platform pin entirely.

Other

  • lerd-tray.service caps its restart loop at 3 failures per 60 seconds. Two unrecoverable failure modes (missing GTK runtime, no graphical session under launchd's bootstrap context) used to spin the unit through RestartSec=2 Restart=on-failure indefinitely; the cap stops the churn while leaving normal transient failures recoverable.
  • DNS sudoers rules use wildcards in command arguments, breaking install on strict-sudo distros (#269, #272). Already shipped in v1.18.1, included here for completeness across minor branches.

Security

  • Bumped npm dev dependencies to clear five medium-severity Dependabot advisories (#277). internal/ui/web upgraded vite 5 to 7.1, @sveltejs/vite-plugin-svelte 4 to 6.2, and vitest 2 to 3.2, which resolves vite to 7.3.2 (path traversal in .map handling) and esbuild to 0.27.7 (dev-server SOP). docs/package.json keeps vitepress at 1.6.4 and uses npm overrides to pull vite ^6.4.2, esbuild ^0.25.0, and postcss ^8.5.10 (XSS in CSS stringify) into vitepress's transitive tree. Both manifests now report zero vulnerabilities under npm audit.

Removed

  • service_remove and service_update MCP tools removed as standalone entries; folded into service_control action enum (which now also accepts migrate, rollback, restart, reinstall).
  • MySQL 5.6 alternate removed from the preset list. 5.7 and 8.0 remain pickable; 8.4 is the new canonical install.

[1.18.1] — 2026-04-29

Fixed

  • DNS sudoers rules use wildcards in command arguments, breaking install on strict-sudo distros (#269, #272). Ubuntu 26.04 LTS made sudo-rs (the memory-safe Rust rewrite of sudo) the default; sudo-rs's parser rejects wildcards in command arguments outright. The same pattern is rejected by upstream C sudo from 1.9.16 onward, which ships on Fedora 41+, Arch / CachyOS, openSUSE Tumbleweed, and NixOS unstable. The cp /tmp/lerd-sudo-* /etc/... and resolvectl <verb> * rules lerd wrote to /etc/sudoers.d/lerd never matched on those parsers, so every DNS reconfigure fell through to the password-prompt path and emitted parse errors visibly during lerd install ("wildcards are not allowed in command arguments"). Fixed by piping content through sudo tee <fully-qualified-path> instead of staging in /tmp and copying, and by dropping the trailing * from the resolvectl line. The Darwin path got the same treatment so future Apple-bundled sudo updates don't surface the same break. Existing installs heal automatically on the next lerd install: one password prompt to migrate, then the new rules grant passwordless operation for every subsequent DNS reconfigure. Verified end-to-end on a fresh Ubuntu 26.04 LTS VM running sudo-rs 0.2.13.

[1.18.0] — 2026-04-25

Added

  • FrankenPHP runtime (#229). Per-site dunglas/frankenphp container as an alternative to the shared PHP-FPM image. Laravel and Symfony adapters; lerd runtime frankenphp CLI and site_runtime MCP tool to switch; optional worker mode (Laravel Octane, Symfony's FrankenPHP adapter with --watch). Runtime badge shown in both the Web UI and TUI. Paused sites stop/start their per-site container alongside FPM.
  • Dual-stack IPv4 + IPv6 networking (#230, #247). The lerd podman bridge is created with both subnets (fd00:1e7d::/64 for v6). Nginx vhosts listen on [::], dnsmasq answers AAAA for .test, and every managed PublishPort gets paired with a [::1] bind. Existing v4-only networks auto-migrate on the next lerd install: containers stop, the network is recreated, previous DNS servers are restored, and containers restart. Hosts without a usable IPv6 address (no non-loopback, non-link-local v6 on any interface) are detected via /proc/net/if_inet6 + the disable_ipv6 sysctl plus a throw-away aardvark probe, and the network is created (or recreated) v4-only. A marker file prevents re-entering the migration loop. See architecture and troubleshooting.
  • --no-ipv6 flag and LERD_DISABLE_IPV6=1 on lerd install (#251). Force a v4-only lerd network on dual-stack-capable hosts without touching the host networking stack. Reuses the existing ~/.local/share/lerd/ipv6-probe-failed-lerd marker, so EnsureNetwork honors the opt-out on every path. Re-enable by deleting the marker and rerunning lerd install.
  • Three new service presets: memcached, rabbitmq, elasticsearch (#252). Standard preset convention (lerd service preset <name>). The rabbitmq preset exposes the management UI at http://localhost:15672; elasticsearch binds 127.0.0.1:9200 so the bundled elasticvue preset becomes a one-click install on top.
  • Streaming preset install in the Web UI (#257). POST /api/services/presets/{name} returns application/x-ndjson with events for installing_config, starting_deps, pulling_image, starting_unit, waiting_ready, and done. The image pull is now explicit and happens before StartUnit, so the formerly invisible on-demand pull surfaces as live Copying blob … feedback. The Add button's label tracks the active phase ("Pulling image…", "Starting elasticsearch…", "Waiting for ready…") instead of one opaque "Adding…". The CLI (lerd service preset <name>) and the MCP service_preset_install tool keep their existing synchronous behavior.
  • Offline landing page for the installed PWA (#258). A service worker ships with the dashboard and falls back to a dedicated offline page whenever lerd-ui is unreachable, including the whole-stack lerd quit case. The page shows lerd start with a copy button and the lerd logo, probes /api/status every five seconds, and auto-reloads the dashboard the moment the backend returns. /api/* is deliberately not intercepted so the WebSocket and every mutating call keep their normal error semantics. Cache name is versioned with the lerd build so every update invalidates the previous shell cache cleanly.
  • Service version label across every surface (#246). lerd service list, lerd status, the Web UI service list and detail header, and the TUI services pane now show the version alongside each built-in, preset, and custom service (e.g. mysql v8.0, redis v7, postgres v16, meilisearch v1.7). Derived from the installed quadlet's Image= tag via podman.ServiceVersionLabel, which strips distro/variant suffixes (-alpine, -slim, -3.5), keeps leading v, and passes rolling tags (latest, main) through verbatim.
  • Restart button in the Web UI service detail (#246). Built-in and custom services now expose a Restart action alongside Start/Stop, matching the site container row. POST /api/services/{name}/restart wraps podman.RestartUnit and clears the paused flag on success. Workers (queue, schedule, horizon, reverb, stripe, site-scoped custom workers) are intentionally excluded.
  • setup MCP tool (#240). Runs the framework's Default: true bootstrap commands (Laravel: storage:link + migrate; Symfony: doctrine:migrations:migrate when doctrine-migrations-bundle is installed). Agents call it after env_setup on new or cloned projects; idempotent, no prompts. The interactive lerd setup CLI is unchanged.
  • Uninstall teardown prompts (#235). lerd uninstall now prompts independently for:
    • Remove MCP integration (global skills + per-site .claude/.cursor/.junie/.mcp.json entries, preserves other MCP servers in shared files).
    • Uninstall mkcert CA from system trust stores.
    • Purge lerd-built container images (lerd-php*-fpm:local, lerd-custom-*:local, lerd-dnsmasq:local; upstream pulls like mysql/redis are deliberately kept, data lives in host bind mounts not in the images). --force answers yes to all.
  • lerd install refreshes MCP skills and heals Claude Code registration (#235, #240). Global skill files (~/.claude/skills/lerd/, ~/.cursor/rules/lerd.mdc, ~/.junie/guidelines.md) and every opted-in site's per-project copies are re-written on install to match the new binary; previously this only ran on lerd update. If Claude Code's user-scope MCP config has lost the lerd entry, install also re-adds it via claude mcp add.
  • Stale-site auto-cleanup covers non-parked sites (#239). The 30 second watcher sweep now removes any registered site whose directory has been deleted, not only those under parked_directories. Publishes a sites eventbus event so the dashboard reflects the removal without a manual refresh.

Changed

  • BREAKING — slimmer MCP tool manifest (#232). The tools/list response merged action pairs (queue_start + queue_stopqueue(action: ...), service_start + service_stopservice_control(action: ...), and similar) and trimmed long descriptions. AI sessions started against the old tool names must be restarted. The new names are reflected in the injected SKILL.md and in docs/features/mcp.md.
  • project_new runs composer install after scaffolding (#240). The create-project --no-install scaffold is chased by composer install inside the FPM container, so the returned project has a populated vendor/ ready for env_setup + setup.
  • env_setup auto-creates database/database.sqlite non-interactively (#240). Laravel's default DB_CONNECTION=sqlite triggered an interactive prompt in lerd env that MCP/script callers silently skipped, leaving the sqlite file uncreated and the first request 500'ing. Non-interactive callers now default to sqlite, persist the choice to .lerd.yaml, and run the existing file-creation block. Call db_set to switch to mysql/postgres afterwards.
  • Per-session MCP token cost reduced (#236, #237). tools/list trimmed ~14% (20 KB → 17 KB), injected SKILL.md trimmed from 44 KB → 40 KB by collapsing redundant single-tool workflow recipes. Descriptions are preserved where weaker local LLMs rely on them (site fields, path defaulting, enum-valued descriptions).
  • SKILL.md bootstrap workflows rewritten (#240). Replaced the per-framework artisan migrate / console doctrine:migrations:migrate fork with a framework-agnostic sequence: new project = project_new → site_link → env_setup → setup, cloned project = site_link → composer install → env_setup → setup. Debug-500 flow calls setup() for pending migrations.
  • Install flow starts per-site containers and stripe workers in the correct phase (#234). lerd install now starts per-site custom containers and FrankenPHP runtimes after service containers, and stripe listeners fire in the worker phase instead of during restoreSiteInfrastructure (no more "stripe starts before FPM" out-of-order).

Fixed

  • Aardvark-dns drift after network recreation (#234, #240). When a network is rm'd and recreated with the same name, netavark can preserve the old listen-ips header in aardvark's runtime config, stalling every container DNS lookup ~5 seconds while glibc waits for the non-listening gateway to time out. EnsureNetwork now detects the drift (via AardvarkNetworkDrifted) and triggers a recreate; both the dual-stack migration path and lerd uninstall's network teardown wipe $XDG_RUNTIME_DIR/containers/networks/aardvark-dns/<name> between rm and create so the condition can't re-occur.
  • Custom container sites not started after lerd install (#234). install.go now calls startPerSiteContainers after startRestoredServices, so lerd-custom-<site> units come up alongside FPM and global services. Previously they sat enabled-but-stopped until the user ran lerd start.
  • Stripe listeners started before FPM and nginx were up (#234). restoreSiteInfrastructure called StripeStartForSite synchronously, unlike other workers which write their unit file and defer Start to the worker phase. New writeStripeUnit / StripeRestoreUnit split so the start fires in startRestoredServices's worker phase, matching queue/schedule/reverb ordering.
  • lerd share collapsed https asset URLs on LAN (#231). HTTPS sites sharing on LAN had asset URLs stripped back to HTTP by the nginx rewrite; the collapse now only fires for http assets on https pages.

Docs

  • New "Runtime" section in the commands reference covers lerd runtime fpm|frankenphp and its --worker / --no-worker flags.
  • Laravel and Symfony getting-started pages mention FrankenPHP / Octane / Symfony Runtime as optional alternatives to the shared PHP-FPM stack.
  • Herd comparison table gains a FrankenPHP / Octane row (Lerd: built-in, free; Herd: Pro-only).
  • Architecture dual-stack section and the remote-development "Security caveats" list note v4-only firewall rules bypass and globally-routable v6 SLAAC LAN reach.
  • Landing page: two-column hero for MCP + Rootless Podman, new Framework store and Polyglot sites cards, trimmed copy to a 4-row max; hero text scaled down to fit "Local PHP development for Linux" on one line.
  • README feature list mentions FrankenPHP; MCP example updated to the post-1.18 site_link → composer install → env_setup → setup sequence; tool count corrected to ~50 after the #232 manifest slim.
  • New troubleshooting entry for the aardvark-dns drift case (symptoms, cause, manual verification via the aardvark config file).
  • Uninstall instructions now cover the three new teardown prompts and --force semantics.
  • Lifecycle reference documents the stale-site auto-cleanup (fsnotify fast path + 30s sweep + eventbus refresh).
  • docs/features/mcp.md tool table adds setup and updates example interactions to the four-step bootstrap sequence.
  • Getting-started guides (laravel / symfony / wordpress) mention setup in the AI-assistant tip.

CI

  • Skip docs deploy and brew tap upload on pre-release tags (#233). The docs site and the Homebrew tap now track stable tags only; beta and rc tags still build binaries but don't publish.
  • Scheduled check-upstream-php workflow now actually triggers a base-image rebuild (#256). The dispatch step ran with the default restricted GITHUB_TOKEN so createWorkflowDispatch returned 403 Resource not accessible by integration, and the digest cache was saved before the failing trigger job, advancing the cached digests without an actual rebuild. Jobs are merged, permissions: actions: write is declared on the job, and the cache save is gated on dispatch success. On failure the prior cache is preserved so the next cron retries.

[1.17.1] — 2026-04-20

Fixed

  • Services not started after fresh macOS install. ensureServiceQuadlet on macOS called WriteContainerUnitFn which only writes the launchd plist, not a .container file in QuadletDir. startRestoredServices later reads that directory via quadletImage() to decide what to pre-pull; with no file it returns empty and skips the pull, leaving podman run to auto-pull mysql/postgres/etc. on a brand-new Podman Machine where those pulls often fail or time out. Switched to WriteQuadletDiff, which writes both the .container file and the launchd plist via AfterQuadletWriteFn, so pre-pull works on first install.
  • PHP FPM images silently missed on first install. ensureFPMQuadletTo wrote the launchd plist only after a successful image build; a failed build left the PHP version unregistered, invisible to ensureImages(), and never retried. The plist is now written before the build so the version shows up in lerd status (as image missing) and lerd start rebuilds it on the next run. lerd install's autostart block also re-runs ensureImages() before starting FPM containers so transient build failures heal automatically.
  • Image pulls routed through lerd-dns on macOS install. ensureImages() ran after ConfigureResolver(), so every registry pull (nginx, DNS, services, FPM) went through the .test override. Moved the call before the DNS block so pulls use the clean system resolver.
  • .container file missing for service and custom-container units on macOS. All call sites (FPM, custom services, custom containers, UI server, MCP server) used WriteContainerUnitFn, which on macOS writes only the launchd plist. quadletImage() then had no file to read, so the pre-pull step was skipped and containers failed on first start. Every site now calls WriteQuadletDiff, which writes both artifacts.
  • Certificates reissued on every lerd setup. IssueCert re-ran mkcert even when the cert and key were already present. Now skips when both files exist.
  • Shims broken under Homebrew installs. php/composer/laravel shims hardcoded ~/.local/bin/lerd as the target binary, so they failed for Homebrew installs at /opt/homebrew/bin/lerd. Shims now resolve the running binary with os.Executable() and use whichever path ran lerd install.
  • PHP commands failing on fresh installs because .env services weren't running. ensureServicesForCwd only acted on paused sites, so mysql/postgres/etc. referenced in a site's .env stayed down and migrations failed with connection errors. It now starts any referenced service that isn't running, silently, regardless of pause state.
  • Dashboard preset install left services stopped. InstallPresetByName only wrote the quadlet without starting the container, so services added from the Web UI (mongodb and others) sat idle after install. The UI now starts dependencies and then the service itself, matching the dashboard's Start button.
  • lerd share URL unreachable while a VPN is active. detectPrimaryLANIP returned the VPN tunnel address (utun*/tun*) instead of the physical LAN interface, so the shared URL worked on the host but nothing else on the LAN could reach it. The detector now validates which interface the routing-table probe selected and falls back to scanning physical interfaces, skipping utun, tun, tap, wg, and container bridges.
  • fnm install fails on ARM Linux. The installer only fetched fnm-linux.zip, which is x86_64-only; arm64 machines need fnm-arm64.zip. Arch detection now picks the correct archive, matching the existing logic used for mkcert.
  • Go test runs hang for the full timeout. installCompletion called os.Executable() during tests; the resulting path pointed at the test binary, which then re-invoked itself with completion bash and hung until CI's 10-minute timeout. The installer now skips when the executable name ends in .test.

[1.17.0] — 2026-04-20

Added

  • Nginx per-site overrides (#225). User snippets dropped in ~/.local/share/lerd/nginx/custom.d/{domain}.conf now survive every vhost regeneration and every lerd update. Each generated server block ends with an include that pulls that file in, and lerd never writes into custom.d/ so your edits stay put. Fixes #223.
  • X-Forwarded- propagation into PHP* (#225). Generated vhosts now set HTTP_HOST, SERVER_NAME, HTTP_X_FORWARDED_HOST, HTTP_X_FORWARDED_PROTO, HTTP_X_FORWARDED_PORT, HTTP_X_REAL_IP, and HTTP_X_FORWARDED_FOR via two http-level map blocks ($real_forwarded_host, $real_forwarded_proto) declared once in a new conf.d/_forwarded.conf. Direct browser access is unchanged because the maps fall back to $host and $scheme; tunnels like lerd share, ngrok, and cloudflared now produce correct absolute URLs out of the box, without any app-side trust_proxies config. Fixes #224.
  • Global AI skill docs refreshed on lerd update (#222). mcp:enable-global now also writes user-scope SKILL.md, cursor rules, and junie guidelines so AI assistants know about the current lerd MCP tools. lerd update rewrites those three files from the new binary whenever global MCP is enabled, keeping them aligned with any added or renamed tools. The gate detects both Claude user-scope registration and the lerd-owned marker files, so users without Claude Code installed are still covered.
  • TUI responsive layout, scrollbars, and color refresh (#217). Below 100 columns the dashboard stacks into a narrow layout: list pane (40%) above detail (60%), v toggles between sites and services, tab cycles only through the active list and detail. Sites, services, and the site detail pane gained a scrollbar; the log pane is scrollable with { and } and its header shows the current offset. Colors were rebalanced to match the web UI palette: emerald for running, violet for accents, amber for paused, red for failing.

Fixed

  • Country-code TLDs incorrectly encoded in auto-generated site names (#221). SiteNameAndDomain used a curated TLD list that missed most ccTLDs, so a directory named astrolov.ro produced astrolov-ro.test instead of astrolov.test. Replaced with a regex matching any trailing two-letter suffix, covering every ISO 3166 code without a maintenance list. Multi-letter gTLDs (.com, .net, .info, .dev, .app, .ltd, and friends) stay on the curated list so app.v2 and backup.old survive unchanged.
  • Invalid AWS_BUCKET names on rustfs sites (#220). The framework template wrote the underscored database handle, which rustfs rejects. envMap["AWS_BUCKET"] now flows through s3BucketName on every run so stale invalid values auto-heal, and a new template placeholder resolves to the S3-safe form. Existing sites with a broken bucket name are repaired on the next lerd env.
  • Auto-stop skipped Podman services on macOS after all sites were paused (#216). Services using Podman's --restart=always policy sit with a launchd plist in the "not running, never exited" state; UnitStatus fell through to ContainerRunning, but a transient podman inspect failure (common under VM socket contention) returned "failed" and auto-stop silently skipped the service. ContainerRunning is now the authoritative check, with UnitStatus kept as a fallback when the container is not found. Postgres and meilisearch now stop as expected.

Changed

  • Docs workflow deploys only on tag release (#219). The GitHub Pages deploy now triggers on v* tag pushes instead of every push to main, so the published site tracks tagged versions and doesn't republish on internal-only merges.

[1.16.0] — 2026-04-17

Added

  • lerd tui — terminal dashboard. A btop-style, full-screen dashboard for sites, services, and workers, with near parity to the Web UI and System Tray. Built on the same bubbletea / lipgloss stack already used for lerd man and the same siteinfo / podman.Cache / eventbus plumbing that drives lerd-ui, so both surfaces see identical live state.

    • Layout: Sites + Services stacked in the left column, a full-height Site detail pane on the right, and a toggleable log tail below (l). Header shows DNS / nginx / FPM status plus an update: vX.Y.Z banner when a newer release is cached.
    • Site detail: primary domain header, internal name, disk path, full domains list (add with a, rename with e, remove with x), services-used with live state, workers (toggle with space), git worktrees, HTTPS toggle, LAN share toggle (shows http://<lan-ip>:<port> when on), PHP and Node version pickers (open with space, commit with enter, backed by lerd isolate / lerd isolate:node).
    • Services pane includes site-owned workers (queue-<site>, schedule-<site>, horizon-<site>, reverb-<site>, custom framework workers), routed through lerd queue start/stop, lerd worker start/stop <name>, etc. t opens an interactive shell in the focused container (FPM or custom for sites, the service container for services, the owning site's FPM for workers).
    • Filter + sort: / to filter sites / services by name (sites also match domains and framework label), o to cycle sort (sites: name · status · framework; services: name · status · usage). v hides the services pane.
    • Log sources: [ / ] cycle through FPM / custom container, every worker journal (journalctl --user -u lerd-<kind>-<site>), and every file matched by the framework's fw.Logs globs (Laravel: storage/logs/*.log). Logs pane takes at least half the window and has a right-edge scrollbar.
    • In-pane overlays: S swaps the detail pane for global Settings (LAN expose, autostart on login, Xdebug per PHP version) and moves focus into it; ? swaps it for a scrollable Keybindings reference. esc returns to Site detail.
    • Updates live by subscribing to the in-process eventbus and re-querying every 2 s so changes made from another terminal surface within a couple of seconds.
  • Selectable Xdebug mode per PHP version (#205). lerd xdebug on now accepts --mode (debug, coverage, profile, trace, develop, gcstats, or comma combos like debug,coverage); previously mode was hardcoded to debug. The dashboard gains a mode dropdown next to the Xdebug toggle and a clickable Xdebug chip on each site row. The MCP xdebug_on tool accepts a mode argument. Toggle orchestration (validate → persist → write ini → restart FPM) is extracted into a new xdebugops package; the three surfaces (CLI, UI, MCP) are now thin wrappers. Legacy configs with no saved mode resolve to debug so existing setups are unaffected.

  • Adaptive Podman Machine memory on macOS (#206). The VM memory target now scales with host RAM instead of a fixed 4 GB floor: 3 GB on machines with ≤8 GB, 4 GB on 9–31 GB, 6 GB on 32 GB+. Detection uses sysctl hw.memsize; falls back to 4 GB when detection fails. On 8 GB MacBooks lerd prints a note with the manual override command (podman machine set --memory 4096) so the tradeoff is visible.

  • lerd quit stops the Podman Machine VM on macOS. After all containers, workers, the Web UI, watcher, and tray are shut down, lerd quit calls podman machine stop on any running machine. lerd start already starts the machine, so quit and start are now fully symmetric. No change on Linux where Podman runs natively without a VM.

Fixed

  • Worker log tabs stuck on "connecting..." in the dashboard (#210). Two separate bugs combined. First, silent units (no output until an event fires) never triggered the first body write, so Go's http.ResponseWriter never sent the HTTP 200 + text/event-stream headers and the browser's EventSource stayed in CONNECTING. Fixed by flushing a : connected SSE comment immediately after writing headers. Second, switching tabs opened a new EventSource without closing the previous one; after a few clicks all six browser HTTP/1.1 connections were consumed and new streams queued indefinitely. Fixed by closing every non-active worker log stream before opening the new one.

  • Git worktrees running stale code from the main checkout (#209). vendor/ and node_modules/ in a new worktree were symlinks back to the main repo. PHP resolves __DIR__ through symlinks, so vendor/autoload.php reported the main repo path and Composer's ClassLoader loaded every class from main's source tree, silently ignoring any diverged app/ or src/ files in the worktree. The symlinks are now replaced with real copies seeded from the main repo using reflink-aware helpers (cp -a --reflink=auto on Linux btrfs/xfs, cp -Rc on macOS APFS, plain Go walk elsewhere), followed by composer install and npm ci to reconcile against the worktree's own lockfiles.


[1.15.1] — 2026-04-16

Fixed

  • lerd.localhost 504 on rootless Linux. The dashboard vhost reverse-proxied to host.containers.internal:7073, which on rootless podman setups where netavark resolves that name to 169.254.1.2 but doesn't wire up a bridge alias or DNAT for it routed packets into a dead end, and the proxy hop timed out after 60 seconds. lerd-ui now also binds a unix domain socket at ~/.local/share/lerd/run/lerd-ui.sock, the lerd-nginx quadlet bind-mounts that path read-write, and the Linux vhost proxy_passes through http://unix:... instead of TCP. Unix sockets depend on filesystem access, not container networking, so the dashboard no longer breaks when your netavark/pasta/rootless stack shifts between versions or your host changes networks. macOS keeps the TCP path via host.containers.internal:7073 because unix sockets don't traverse the podman-machine virtio-fs boundary as functional sockets, and gvproxy reliably forwards that upstream there.
  • Xdebug times out silently on rootless Linux (same class of bug as #186). The 1.13.1 fix replaced a hardcoded 169.254.1.2 with a dynamic getent hosts host.containers.internal probe but still trusted whatever netavark returned without checking it actually routed. On setups where netavark gives the same 169.254.1.2 back, the fix is a no-op and Xdebug fails with Time-out connecting to debugging client exactly as before. DetectHostGatewayIP now runs a real reachability probe: from inside lerd-nginx, TCP-connect to lerd-ui:7073 for each candidate (getent's answer, the host's primary LAN IP, slirp4netns's 10.0.2.2) and use the first that opens. If nothing works, fall back to the legacy constant and surface the failure in lerd doctor under a new [Container → Host connectivity] section so users get a concrete diagnosis instead of silent retries.
  • Xdebug breaks when the laptop changes networks. A probe at lerd start pins a LAN IP into /etc/hosts, which goes stale the moment you move from home wifi to a coffee shop or rotate DHCP. New lerd-watcher goroutine reprobes the host gateway on LAN change and rewrites the shared /etc/hosts in place, so PHP-FPM containers pick up the new address on the next getaddrinfo call without a container restart. Steady-state cost is near zero: a single net.Dial("udp4", "1.1.1.1:80") routing-table lookup per 30 s tick (never sends a packet, just reads the kernel's source IP for the default route). The expensive probe only runs when the primary LAN IP actually changes. Matters most on macOS where each podman exec through gvproxy costs 300 ms to 1 s, so a naive probe-every-tick design would burn 1-3 % of a core continuously on battery.
  • Podman auto-creates a directory at missing bind-mount source paths. When the FPM container starts before an ini file has been written, podman satisfies the Volume= clause by creating the source as a directory, and the next write against that path either silently no-ops (EnsureUserIni returned early on the os.Stat success without checking IsDir) or fails with is a directory (WriteXdebugIni, the inline hosts-file pre-create). Fix: EnsureXdebugIni and WriteXdebugIni detect a stale directory and remove it before writing; EnsureUserIni got the same self-heal; the inline hosts pre-create was extracted into ensureFPMHostsFile which normalises stale-directory, missing, and regular-file states into "regular file present"; WriteContainerHosts and writeBrowserHosts now MkdirAll their parent instead of assuming the data dir already exists. Scanned every Volume= source on the embedded FPM, nginx, and service quadlets to confirm these three file sources were the remaining ones needing pre-creation (directory-typed mounts like data/* and conf.d are safe because podman creating them is the right behaviour).
  • Dashboard shows containers as still running after lerd stop. The in-process AfterUnitChange hook refreshed podman.Cache before broadcasting, but the /api/internal/notify endpoint that CLI and MCP processes use to signal unit lifecycle changes only invalidated the siteinfo cache and published events without refreshing the container cache. Site/FPM running flags read from podman.Cache, so after lerd stop the browser kept reporting everything as up until the 15-60 s background poller next ticked. The notify handler now also calls podman.Cache.PollNow() in a goroutine so state flips within a second of the CLI exiting while the handler still returns under the 500 ms POST timeout.

[1.15.0] — 2026-04-16

Added

  • Per-project custom container support (#198). Non-PHP sites (Node.js, Python, Go, Ruby, etc.) can define a Containerfile.lerd and a container: section in .lerd.yaml. Lerd builds a dedicated image, runs it as a named container, and nginx reverse-proxies to it. Full lifecycle: lerd link builds and starts, lerd unlink stops and cleans up (prompts to remove the image), lerd secure/lerd unsecure toggle HTTPS, lerd pause/lerd unpause stop and start the container, lerd restart restarts without rebuilding, lerd rebuild forces a fresh image build. Workers defined in custom_workers exec into the container. Services are reachable by name (lerd-mysql, lerd-redis, etc.) on the shared Podman network.
  • lerd restart command. Restarts the container for any site type: the per-project custom container for custom sites, or the shared PHP-FPM container for PHP sites. Also available as site_restart MCP tool and in the dashboard (restart icon in the site header).
  • lerd rebuild command. Rebuilds the custom container image from the Containerfile and restarts the container. Also available as site_rebuild MCP tool and POST /api/sites/{domain}/rebuild in the dashboard.
  • lerd init custom container wizard. When no PHP project is detected (no composer.json, no framework) and a Containerfile.lerd exists, the wizard switches to custom container mode and asks for the container port, containerfile path, HTTPS, and services.
  • Containerfile MD5 caching. lerd link skips the image build when the Containerfile hasn't changed since the last build. The hash is stored in ~/.local/share/lerd/container-hashes/. lerd rebuild always forces a fresh build.
  • Dashboard: custom container UI. Container icon (cube) in the sidebar, base image badge (e.g. node:22-alpine :3000) instead of the PHP dropdown, "Container" logs tab, restart button, worker toggles for custom_workers, running/stopped status reflecting the custom container.
  • site_restart and site_rebuild MCP tools. Skill content updated with custom container architecture, .lerd.yaml reference including container and custom_workers fields, setup workflow, and env var configuration guidance.

Fixed

  • Watcher overwriting custom container sites. The site file watcher and siteinfo.enrichVersions no longer re-detect PHP/Node versions for custom container sites, preventing the empty values from being overwritten with defaults.
  • Parked watcher re-registering custom containers. RegisterProject now skips sites already registered as custom containers.
  • Service auto-stop ignoring .lerd.yaml. CountSitesUsingService and sitesUsingService now check .lerd.yaml services list in addition to .env scanning, preventing auto-stop of services used only by custom container sites.
  • Domain change producing 502. RegenerateSiteVhost now uses custom container vhost templates for custom sites instead of PHP templates.
  • lerd install/lerd update overwriting custom vhosts. The vhost regeneration during install now branches for custom container sites.
  • lerd start/lerd stop trying to start/stop workers for ignored sites. registeredFrameworkWorkerUnits now skips ignored and paused sites.
  • lerd pause/lerd unpause not stopping/starting custom containers. Pause now stops the custom container, unpause starts it and restores the proxy vhost.

[1.14.1] — 2026-04-16

Fixed

  • Node version dropdown missing from site rows in the dashboard. The 1.14.0 node_managed_by_lerd gate was implemented as an outer <template x-if> wrapping two inner templates (empty-list placeholder and populated <select>). Alpine.js's x-if directive only renders a single child element, so the outer template silently rendered nothing and the Node dropdown disappeared for every site, even on machines where lerd manages Node. Flattened into two sibling templates that each include the node_managed_by_lerd condition inline, matching the existing PHP dropdown pattern.

[1.14.0] — 2026-04-16

Added

  • Node version management (#191). Lerd now detects whether Node is managed by the system (distro package, nvm, fnm, mise, asdf, volta) or by lerd itself, and adapts the UI and init wizard accordingly. On machines where Node is system-managed, the dashboard shows a "system" badge next to the Node.js sidebar section, hides the per-site Node version dropdown, and the lerd init wizard omits the Node version input (an existing node_version in .lerd.yaml is preserved). The status API gains node_managed_by_lerd. Also fixes a UI regression where installing Node from the dashboard could emit a spurious "unknown version" error.
  • Decoupled lerd db:* commands (#192). lerd db:import, db:export, db:create, and db:shell now work in any project type (NestJS, Next.js, Go, Rails, etc.) without requiring a linked site or PHP-style .env. Resolution chain (first match wins): --service flag, .lerd.yaml db: block, framework detect rules, then generic .env inference (DB_CONNECTION / DB_TYPE / TYPEORM_CONNECTION / DATABASE_URL / DB_PORT). Credentials from .env are intentionally ignored, because lerd always connects via podman exec using the container's fixed admin credentials (postgres/lerd or root/lerd), so a mismatched DB_USERNAME=root against a pgsql container no longer fails with role "root" does not exist. db:shell now checks whether the target database exists and prompts to create it before opening the shell, instead of dumping a raw psql error.

Changed

  • Skip .env backup when lerd has already written the file (#193). lerd env used to unconditionally copy .env to .env.before_lerd on first run, which could overwrite a legitimate user backup if lerd had previously rewritten the file. The backup is now skipped when lerd has already written .env in this project, so .env.before_lerd always reflects the user's pre-lerd state.
  • Tray improvements (#194). The tray "Open Dashboard" entry now opens the dashboard in the default browser, the update prompt wording is clearer, and "Quit" now stops the full lerd-ui + daemon stack instead of just dismissing the tray.

[1.13.1] — 2026-04-14

Fixed

  • Xdebug and inter-site HTTP inside PHP-FPM containers (closes #186). The shared /etc/hosts bind-mounted into every PHP-FPM container used to hardcode 169.254.1.2 both for host.containers.internal and for every linked .test domain. That address is only a valid host gateway on rootless podman with pasta/netavark/slirp4netns, so Xdebug timed out connecting back to the IDE on other podman configurations. It also routed inter-site HTTP through a fragile FPM → pasta host-loopback → host 127.0.0.1:80 (rootlessport) → lerd-nginx chain that failed on some podman versions and surfaced as 504s during debugging. WriteContainerHosts now probes the real host gateway by exec-ing getent hosts host.containers.internal inside lerd-nginx, with a throwaway alpine container on the lerd network as fallback, and the old constant as a final fallback. Two distinct IPs are written: host.containers.internal points at the detected host gateway for Xdebug and any host-side tooling, while every .test domain resolves straight to lerd-nginx's bridge IP so inter-site HTTP travels container-to-container over the lerd network without any pasta hop. Rendering was extracted into a pure renderContainerHosts helper with table-driven unit tests covering empty registries, nginx IP wiring, IP separation regressions, and loopback preservation.

[1.13.0] — 2026-04-14

Added

  • lerd lan:share / lerd lan:unshare — expose a single site to other devices on the local network at a stable http://IP:PORT URL with no client-side DNS setup. A host-level reverse proxy runs in the lerd-ui daemon, rewriting the Host header so nginx routes to the correct vhost and rewriting absolute URLs (https://domainhttp://LAN-IP:port) in HTML, CSS, and JS bodies so assets and redirects work without DNS. Accept-Encoding: identity is forced upstream and gzip is decoded in ModifyResponse to keep body rewriting reliable, and Location headers are rewritten on redirects. Each site gets a stable port from 9100 onwards (saved in sites.yaml, restored on daemon start) that avoids conflicts with Reverb and other services. The CLI prints a compact half-block Unicode QR code after sharing; the dashboard UI adds a LAN toggle next to HTTPS with the URL inline and a fixed-positioned QR tooltip on hover (fixed positioning escapes overflow-x-auto clipping on parents). QR PNGs are served from /api/lan-qr/{domain}. Closes #179.
  • lerd import sail (alias lerd sail import) — migrate an existing Laravel Sail project into lerd without manual dump/restore. Detects Docker or Podman Compose, remaps conflicting ports and strips non-data service ports so Sail starts cleanly alongside lerd, waits for the database, auto-detects the Sail DB name (handles the case where lerd env already overwrote DB_DATABASE), dumps the DB from the Sail container into lerd's MySQL/PostgreSQL, reads MinIO credentials from the compose environment block, and mirrors the bucket into RustFS via mc. Tears Sail back down when done (--no-stop keeps it running). lerd env now backs up .env.env.before_lerd on first run and lerd env:restore brings it back. lerd link detects laravel/sail in composer.json and prompts to run the import before setup, passing DB_DATABASE through automatically.
  • Hardcoded bundled preset files (preset_files.go) — preset file mounts (phpmyadmin config, etc.) ship inside the Go binary instead of being copied into ~/.config/lerd/services/*.yaml, so lerd updates roll out new preset contents on the next service start without remove + reinstall. Legacy files: entries in user yaml are auto-stripped and re-saved on load. Newlines and NUL bytes in custom service env values are now rejected to close a quadlet Environment= injection vector.
  • URL hash routing in the dashboard#sites/<domain>, #services/<name>, #system/<section>, #service/<name> (dashboard iframe), and #docs are now deep-linkable with working back/forward navigation. loadSites auto-select only fires when the sites tab is active and the hash doesn't already claim an iframe view, so refreshing on a sub-page stays put.
  • repeat_family dynamic_env directive — produces N copies of a value aligned with the host list from discover_family, used for PMA_USERS / PMA_PASSWORDS so phpmyadmin can pre-auth against every database in a family.
  • GitHub star nudge — low-key prompt added to the installer and dashboard.

Fixed

  • Service dashboards rendered broken inside the iframe overlay — phpmyadmin lost session cookies across the cross-origin iframe and pgadmin's list-databases XHR dropped cookies after the initial connect. The phpmyadmin preset now rebuilds cfg['Servers'] from PMA_HOSTS / PMA_USERS / PMA_PASSWORDS with auth_type=config for multi-host auto-login and sets CookieSameSite=None plus forced HTTPS env so cookies flow inside the iframe. The pgadmin preset sets SESSION_COOKIE_SAMESITE=None and SESSION_COOKIE_SECURE=True for the same reason. Rustfs now starts with --console-enable and the dashboard URL points at /rustfs/console/ so the iframe lands on the web UI instead of the raw S3 XML. The preset picker also hides presets with unmet dependencies (mongo-express disappears until mongo is installed) instead of just disabling the install button.
  • Lerd-ui spawned terminals died silently when started at boot — when lerd-ui runs as a lingering systemd user service it starts before the compositor exists and inherits an empty graphical environment (no WAYLAND_DISPLAY / DISPLAY), so any GUI terminal it forked exited immediately. Clicking site Terminal or "Open terminal & update" did nothing with no visible error. graphicalEnv() now pulls the graphical vars from systemctl --user show-environment and probes XDG_RUNTIME_DIR for a wayland-* socket as a last resort, so spawned terminals can always reach the compositor regardless of how lerd-ui itself was launched. Darwin is skipped because open -a Terminal reattaches to the Aqua session on its own.
  • PHP-FPM subdomain detectionSERVER_NAME is now set to $host in PHP-FPM vhosts so subdomain routing works correctly under nginx.
  • Mobile dashboard layout broken by the iframe overlay — the dashboard iframe assumed the desktop left rail was always visible and extended full-height, hiding the mobile bottom nav, and dashboard service icons only existed in the desktop rail so mobile users had no way to reach them. The overlay now spans full width on mobile and stops above the nav, the mobile bottom nav gains a scrollable dashboards group with a separator, the nav is pinned to h-16 to match the iframe's reserved offset, the docs sidebar link reuses the iframe trigger, and the bottom nav is flattened so built-in tabs and dashboard services share equal width instead of each group claiming half the bar.
  • lerd man indexed node_modules when walking docs — the docs FS walker now skips node_modules so man-page generation no longer drags vendor directories into the index.
  • VitePress build broken on {{.Resources.Memory}} — Vue's compiler parses interpolations even inside backtick code spans, and the leading dot fails JS expression parsing. The token is now wrapped in an explicit <code v-pre> element so Vue skips it.

[1.12.6] — 2026-04-13

Added

  • Background container state cache — a single goroutine polls podman ps -a --filter name=lerd- on a 15 s (focused tab) / 60 s (idle) cadence and serves every hot path (buildStatus, siteinfo, IsActive, /api/sites, /api/services, /api/status) from an in-memory snapshot instead of spawning a podman inspect subprocess per container per request. Snapshot rebuilds are serialised with a TryLock so concurrent requests share the in-flight build rather than each triggering their own batch. Browser Page Visibility is piped over the WebSocket so the server downshifts cache polling when every tab is hidden. The tray poller drops from 5 s to 30 s. Net effect on macOS: the idle VM no longer burns 30–80 % host CPU from repeated podman machine ssh round-trips.

Fixed

  • UI Stop button hung for a minute on slow-stopping services — stopping Selenium (or any custom service using supervisord/Chromium) from the web UI would leave the button spinning for 30–60 s while systemctl waited on the container's graceful shutdown. Custom service quadlets now emit StopTimeout=5 so podman SIGKILLs after 5 s, matching the existing --stop-timeout=5 behaviour on macOS. The UI toggleService handler also wraps the POST in an 8 s AbortController timeout and shows "Stopping in background…" on abort, so the button always releases promptly and the WebSocket snapshot push backfills the final state. Existing installs of affected services can pick up the new timeout with lerd service remove <name> && lerd service preset <name>.
  • Worker entries clobbered on lerd uninstalllerd installWorkerStartForSite called SetProjectWorkers(CollectRunningWorkerNames(...)), which fully replaced the .lerd.yaml workers list on every invocation. Workers started sequentially during lerd setup overwrote each other's entries, so after an uninstall/install cycle only the last-started worker survived. Now uses a new additive AddProjectWorker helper. StripeStartForSite also persists "stripe" after a successful start so it survives the same cycle, and lerd init now lists stripe in the Workers multi-select when STRIPE_SECRET is present in the site's .env.
  • UI worker toggles visually revertedAfterUnitChange published the snapshot broadcast before the container cache had re-polled, so the first frame after toggling a worker carried the old state and the button appeared to flip back. The hook now calls Cache.PollNow() from a goroutine before publishing, so the broadcast always carries fresh data. The activating state is also now treated as running (not failing) for queue/schedule/reverb/horizon and generic framework workers, so the brief startup window no longer flashes a red error indicator.
  • WebSocket initial snapshot could be stalehandleWS called the async Cache.Refresh() before assembling the first frame, meaning a freshly-opened browser could see container states from before lerd start ran. Replaced with the synchronous Cache.PollNow() so the first frame on every new connection reflects current reality, even when multiple tabs reconnect at the same time.
  • Rustfs bucket not created for Laravel projectslerd env only ran the rustfs bucket creation logic inside the fallback knownServices loop. Laravel projects go through fw.Env.Services, which skipped the branch entirely, so Dusk/Panther sites hit "bucket does not exist" errors on first upload. The bucket create/mc anonymous set public logic now runs on the framework service path too, honours an existing AWS_BUCKET value in .env instead of always overwriting with the project slug, and retries up to 3 times (2 s apart) to bridge the window between the host TCP port becoming reachable and the mc container being able to connect over the lerd network.
  • Default PHP FPM was auto-stopped when unusedautoStopUnusedFPMs didn't exempt cfg.PHP.DefaultVersion, so setting a default (e.g. 8.5) with no site explicitly referencing it would stop the container immediately after start, breaking php, composer, and laravel new shims. The helper now mirrors coreUnits() and always keeps the default version running.
  • macOS Podman Machine memory resize fired on every start — the {{.Resources.Memory}} inspect template returns MiB, not bytes, but the comparison assumed bytes. Machines already at 4096 MiB tripped the condition and the CLI stopped + resized the VM on every lerd start. Fixed the unit handling and, while there, lengthened the readiness timeout from 90 s to 120 s with a 3 s grace period, so the post-resize restart no longer races the API socket. ensureDefaultPHPInstalled also now auto-builds the FPM image + writes the quadlet on the first lerd start after switching the configured default PHP version, so users don't have to run lerd php install manually.

[1.12.5] — 2026-04-13

Fixed

  • macOS ARM64 postgres pulled an image with no ARM64 manifestplatformImageOverride was applied before svcCfg.Image from global config, so the macOS substitute (imresamu/postgis) was silently overwritten by postgis/postgis:16-3.5-alpine on every ensureServiceQuadlet and lerd install run. The override now runs last and only when the resolved image is the known-bad upstream postgis/postgis + alpine suffix, leaving user-pinned custom images untouched. The embedded quadlet fallback also moves from postgis:16-3.5-alpine to postgis:16-3.5 so fresh Linux installs get an image with an ARM64 manifest. (#175)

[1.12.4] — 2026-04-13

Fixed

  • pgpass rewrite failed with permission denied — file mounts declared with chown: true (e.g. pgAdmin's /pgpass) get re-owned to a userns-mapped uid by podman's :U flag. On the next materialize the host process could no longer open the file for writing and surfaced open …/pgpass: permission denied. MaterializeServiceFiles now unlinks the existing entry before writing, so a stale userns-owned file is replaced cleanly.

[1.12.3] — 2026-04-13

Fixed

  • pgAdmin crash loop and iframe embedding — pgAdmin now ships a mounted config_local.py that disables X-Frame-Options, ENHANCED_COOKIE_PROTECTION, and WTF_CSRF_CHECK_DEFAULT, so it renders inside the inline dashboard overlay with working sessions and preferences. Also fixes a launchd plist XML escaping bug where ' and " in env var values were emitted as numeric character references that Apple's plist parser passed through literally, corrupting container env and crash-looping pgAdmin on macOS. Adds a Slonik (elephant) icon for pgAdmin in the dashboard rail. (#171)
  • UI service remove left family consumers stale — the web UI's remove handler did not call RegenerateFamilyConsumers, so removing mariadb from the UI left phpMyAdmin's PMA_HOSTS pointing at the gone host. The UI now matches the CLI behaviour. (#172)
  • Workers showed as off on macOS while runningunitStatusFn defaulted to a systemctl-based path that does not exist on macOS, so the UI never reflected running workers. Darwin now overrides it to use podman.UnitStatus, the same path lerd status uses. (#170)

[1.12.2] — 2026-04-13

Added

  • Inline dashboard iframes — service dashboards (phpMyAdmin, pgAdmin, Mailpit, RustFS, Meilisearch, Mongo Express, Selenium…) now open as a full-width overlay inside the lerd UI instead of a new browser tab. The left icon rail grows a separator followed by one stroke icon per running dashboard-exposing service, and the service detail Dashboard / Open phpMyAdmin / Open pgAdmin buttons route through the same overlay. Clicking any of the main nav icons closes it. An Open-in-new-tab escape hatch remains for dashboards that refuse framing or lose session cookies under third-party partitioning. (#168)
  • phpMyAdmin ships AllowThirdPartyFraming — the phpmyadmin preset now materialises /etc/phpmyadmin/config.user.inc.php with $cfg['AllowThirdPartyFraming'] = true; so it renders inside the inline overlay. Existing installs must lerd service remove phpmyadmin && lerd service preset phpmyadmin after upgrading to pick up the new file mount.

[1.12.1] — 2026-04-13

Added

  • macOS release pipeline and Homebrew tap — tagged releases now build darwin amd64/arm64 binaries and publish to the geodro/homebrew-lerd tap. Install on macOS with brew tap geodro/lerd && brew install lerd && lerd install.

Fixed

  • Short-name pulls failed on Ubuntu — built-in service images (mysql, redis, postgis, meilisearch, rustfs, mailpit) were stored as short names and failed on distros whose /etc/containers/registries.conf has no unqualified-search registries. All defaults are now fully qualified with docker.io/, and existing configs are auto-migrated on next load.
  • Installing a preset from the UI did nothing visible — the /api/services/presets/ endpoint did not publish an eventbus event after a successful install, so the 2-second snapshot cache kept returning the stale services list. The frontend's immediate loadServices() then failed to find the new service, leaving the modal open and the dashboard unchanged. The endpoint now invalidates the cache and broadcasts over WebSocket, so the phpMyAdmin (and any other preset) install flow closes the modal, switches to the Services tab, selects the new service, and starts it.

[1.12.0] — 2026-04-13

Added

  • macOS platform support — first-class macOS alongside Linux. Installation, DNS, autostart, start/stop (with Podman Machine), PHP version detection, UI log streaming, and service management are all platform-split. Dedicated macOS CI build and test job.
  • macOS service management — workers UI, tray fix, parallel service start, and LAN support on macOS.
  • WebSocket live dashboard updates — the dashboard now receives push updates over WebSocket instead of polling, cutting idle request traffic and reflecting site/worker state changes immediately. (#161)
  • Scheduled (timer-driven) framework workers — frameworks can declare workers that run on a systemd timer instead of as long-lived processes. Timers are included in lerd start/lerd stop, surfaced in the UI, and detected via sibling .timer units in worker status. (#160)
  • Platform-split UI log streaming — log tailing routes through platform-specific backends (journald on Linux, log(1) on macOS).
  • Cross-platform service management, DNS split, and CLI utilities — foundation for multi-OS support: abstracted service layer, platform-specific DNS handling, and shared CLI helpers.

Fixed

  • Input validation and credential handling hardened — security audit sweep across CLI entry points and credential handling paths.
  • Scheduled-worker lifecycle — orphan .timer files are now skipped and cleaned up, timer-driven workers report as active in the UI, and stopping/tearing down timer-backed workers collapses and cleans up cleanly.
  • Per-version framework resolutionschedule, queue, horizon, and reverb shortcuts now resolve the framework definition per-version instead of falling back to a single global definition.
  • Perpetual service quadlet rewrite on installlerd install no longer rewrites service quadlets on every run, which previously dropped local edits and triggered needless restarts.
  • Skip Laravel installer prompt when already installedlerd setup no longer asks to install Laravel when the project is already initialised.
  • macOS terminal integrationlerd open and the UI terminal button open Terminal.app silently at the project directory; lerd update defers to brew upgrade lerd on macOS.
  • macOS DNS sleep/wake repair, tray startup, and install ordering — DNS survives sleep/wake cycles, the tray starts reliably on login, and install ordering avoids race conditions. Sequential install image pulls keep the sudo prompt visible.
  • Launchctl kickstart hang on tray restartlerd-tray restart no longer hangs inside launchctl kickstart on macOS.
  • RunParallel keypress goroutine swallowed sudo input — the parallel-run keypress watcher no longer competes with sudo for stdin, so password prompts work again.
  • Install linger and sudo prompt UXlerd install enables systemd user linger automatically, and the linger sudo prompt renders on its own line for readability.
  • Default PHP-FPM always starts in lerd start — the default PHP-FPM unit is always brought up, preventing "no PHP handler" errors on fresh boots.
  • Linux worker restore, PostGIS migration, and UI request pile-up — hardened worker restoration on Linux, fixed PostGIS database migration, and stopped the UI from piling up in-flight requests.
  • Remote CA installed into isolated CAROOTlerd setup --remote installs its CA into a dedicated CAROOT so it no longer overwrites the local mkcert root.

Changed

  • Platform-split installation — binaries, DNS, autostart, and cleanup routines are now dispatched through a platform interface rather than hardcoded to Linux.
  • Platform-split start/stoplerd start/lerd stop run through per-platform implementations, including Podman Machine orchestration on macOS.
  • Platform-split PHP version detection — PHP version discovery runs through platform-specific probes.

Docs

  • macOS in the tagline and install docs — the tagline, install instructions, and per-platform update steps now include macOS. Beta wording and "coming soon" / "Linux-only" phrasing removed throughout.

[1.11.0] — 2026-04-11

Added

  • Ptyxis terminal supportlerd open and the tray menu now detect and launch Ptyxis, the GNOME 47+ terminal emulator.
  • Link → init → setup flow — after lerd link, the CLI guides the user through lerd init and lerd setup when the project hasn't been initialised yet.
  • PHP version suggestion during link — when the project requires a PHP version that isn't installed, lerd link suggests installing it.
  • Favicon field in framework definitions — frameworks can now declare a custom favicon path (e.g. core/misc/favicon.ico for Drupal) so the dashboard shows the correct icon.

Fixed

  • Framework detection for custom frameworks — detection rules now read composer.json directly and support custom detect rules, fixing detection for frameworks like Drupal, CakePHP, and WordPress.
  • Worker checks and env setup for custom frameworks — worker check rules and env variable setup now work correctly for non-Laravel frameworks.
  • Favicon detection uses framework public_dir — custom frameworks with non-standard public directories (e.g. web/ for Symfony/Drupal) now have their favicons detected correctly.
  • 0-byte favicon files skipped — empty favicon placeholder files no longer show as having a favicon in the dashboard.
  • Link only writes .lerd.yaml when it already exists — avoids creating an unnecessary config file for projects that don't use one.

Changed

  • Site enrichment consolidated into internal/siteinfo — CLI, MCP, and UI no longer duplicate site enrichment logic. A single LoadAll(flags) function with flag-based enrichment replaces ~340 lines of duplicated code across three packages.
  • Link/unlink core logic extracted into internal/siteops — shared site operations (vhost generation, site naming, linking, unlinking) moved out of the CLI package for reuse by MCP and UI.
  • Framework detection centralisedDetectFrameworkForDir and .lerd.yaml operations moved into the config package, eliminating scattered detection logic.

[1.10.1] — 2026-04-10

Fixed

  • phpMyAdmin (and other dynamic_env presets) connected to wrong host — the Web UI and MCP service_start/service_add code paths generated custom service quadlets without resolving dynamic_env directives, so PMA_HOSTS was never set and phpMyAdmin fell back to its default host db. All three paths now delegate to serviceops.EnsureCustomServiceQuadlet which handles dynamic_env resolution and file materialisation.

[1.10.0] — 2026-04-10

Added

  • Framework definition store — community framework store backed by geodro/lerd-frameworks with lerd framework search, lerd framework install, and lerd framework update commands. Definitions auto-fetch when linking a project and auto-refresh after 24 hours. MCP tools framework_search and framework_install expose the store to AI assistants. (#103)
  • Framework-agnostic worker system — all hardcoded Laravel worker logic replaced with a generic system driven by framework YAML definitions. Dedicated commands (queue, schedule, reverb, horizon) are now aliases that read from the framework definition. Workers support conflicts_with, proxy config with auto port assignment, and port collision prevention across sites.
  • Worker add/remove CLI and MCP toolslerd worker add and lerd worker remove manage custom workers in .lerd.yaml (project-level) or the global framework overlay (--global). Orphaned workers (running units with no framework definition) are detected and surfaced in worker list, worker stop, and setup.
  • PHP version ranges — framework definitions declare supported PHP min/max ranges. lerd link and lerd init clamp the PHP version to the framework's supported range. lerd sites and the UI show the framework version (e.g. "Laravel 11").
  • and template vars — framework env var templates can reference the site's primary domain and TLS scheme. .env keys like APP_URL, VITE_REVERB_HOST, and VITE_REVERB_SCHEME sync automatically when the primary domain changes.
  • Selenium service preset — bundled selenium preset (selenium/standalone-chromium) for browser testing with Laravel Dusk. Auto-detected via composer_detect on laravel/dusk, patches DuskTestCase.php, and includes noVNC on port 7900 for watching tests live. New share_hosts field on custom services maps .test domains to the nginx container IP.
  • Cursor MCP supportmcp:inject and mcp:enable-global now write Cursor configuration (.cursor/mcp.json and .cursor/rules/lerd.mdc). (#132)
  • Ghostscript in PHP-FPMghostscript added to the base PHP-FPM image for PDF manipulation with libraries like Spatie MediaLibrary. (#138)
  • mysql-client in PHP-FPMmysql-client added to the PHP-FPM image so mysqldump works inside lerd php sessions. (#142)

Changed

  • MCP tool responses optimised for AI agents — ANSI escape codes stripped from all CLI output. doctor, check, and env_check return structured JSON instead of raw text. env:check no longer exits non-zero.
  • CI auto-rebuilds PHP images — a scheduled workflow checks Docker Hub daily for upstream php:X.Y-fpm-alpine security patches and triggers a force rebuild when new digests appear.

Fixed

  • php:rebuild reused stale base imageslerd php rebuild now always pulls fresh base images instead of building on top of potentially outdated cached layers. (#140)
  • npm run build failed when node_modules missing — build step is now guarded so it skips gracefully when dependencies haven't been installed. (#133)

[1.9.4] — 2026-04-10

Fixed

  • Extra volume mounts lost after install/updatelerd install rewrote nginx and service quadlets from raw templates, dropping extra volume mounts for projects outside $HOME. Mounts now survive install and update cycles.

[1.9.3] — 2026-04-10

Fixed

  • Projects outside $HOME failed with "chdir: No such file or directory" — the PHP-FPM and nginx containers only bind-mount $HOME, so projects in /var/www, /opt/projects, or similar paths could not be served or exec'd into. Lerd now automatically injects extra volume mounts into both containers when it detects a project outside the home directory. Mounts are added transparently during lerd link, lerd park, or any exec command (lerd php, composer, laravel new) and cleaned up on lerd unlink / lerd unpark. (#120)
  • Env file keys appended instead of uncommented — when a .env key existed but was commented out (#DB_HOST=...), lerd env appended a duplicate instead of uncommenting the existing line in place.

Added

  • lerd doctor checks for crun — warns when crun is not installed, since it is the recommended OCI runtime for rootless Podman.

[1.9.2] — 2026-04-10

Fixed

  • Site service badges missed .env-detected services — badges on the site detail panel only showed services declared in .lerd.yaml. Now also scans the site's .env for lerd-{name} references (both built-in and custom services), matching the same auto-detection logic the Services tab already uses.

[1.9.1] — 2026-04-09

Fixed

  • Queue workers silently lost on uninstall+reinstallqueueStartExplicit ran a Redis preflight that returned an error before the unit file was written. Install-time restoreSiteInfrastructure runs before any services are started, so for sites with QUEUE_CONNECTION=redis the write step always failed and the worker units stayed missing on disk while systemd remembered them as not-found failed. The preflight is gone; the dependency now lives in the systemd unit itself. lerd-queue-<site>.service declares After=/Wants= for whatever the queue backend needs (lerd-redis.service when QUEUE_CONNECTION=redis, lerd-mysql.service / lerd-postgres.service for database-backed queues) on top of the FPM container, and lerd-horizon-<site>.service always declares lerd-redis.service. systemd handles the activation order and Restart=always covers the small ready-window between activation and the backing container accepting connections.
  • Preset-installed services not regenerated on reinstallrestoreSiteInfrastructure only handled inline custom services and built-in named refs. Preset references like mariadb-11 (declared in .lerd.yaml as mariadb-11: {preset: mariadb, version: "11"}) fell through to ensureServiceQuadlet, which only knows about built-ins, so the silently-swallowed unknown service error left sites with no quadlet for any preset-installed service after an uninstall+reinstall cycle. The restore path now goes through ProjectService.Resolve() which already knows how to render both inline and preset references back into a concrete CustomService.

Changed

  • lerd status shows [preset] for preset-installed services instead of grouping them under [custom]. Hand-rolled custom services keep the [custom] label.
  • Tagline rewordedlerd --help, the install.sh banner, and the goreleaser GitHub release notes header now read Lerd — Podman-powered local PHP dev environment for Linux instead of Laravel Herd for Linux — ….
  • Services walkthrough (docs/getting-started/services.md) updated to lead with the bundled preset flow for MongoDB, phpMyAdmin, and pgAdmin (lerd service preset <name>) instead of the hand-rolled YAML each one used to require. Adminer, Elasticsearch, and RabbitMQ stay as full YAML recipes since there's no preset for them yet. Adminer's port bumped to 8083 to avoid colliding with the mongo-express preset on 8082.

[1.9.0] — 2026-04-09

Added

  • Service presets — opt-in bundled service definitions surfaced via lerd service preset (list / install) and a + picker on the Web UI's Services tab. First batch ships phpmyadmin, pgadmin, mongo, mongo-express, and stripe-mock as embedded YAML that becomes a normal custom service once installed, so every existing lerd service subcommand (start/stop/remove/expose/pin) keeps working unchanged. Installed presets are filtered out of the picker; after install the user lands on the new service detail panel and the service auto-starts.
  • Multi-version preset families — presets can declare multiple versions in a single YAML (e.g. mysql 8.0/8.4/9.0, mariadb 10.11/11.4) and lerd service preset shows version pills on list, prompts for a version on install, and persists the chosen tag in .lerd.yaml. Family discovery groups versions by base name in both the CLI list and the Web UI picker.
  • Preset MCP toolsservice_preset_list and service_preset_install expose the preset catalog and install flow to AI assistants, sharing the install path with the CLI through serviceops.InstallPresetByName. Re-run lerd mcp:inject in existing projects to pick up the new tool descriptions.
  • Custom service files: field — declare inline-rendered config files materialised on the host and bind-mounted into the container, with optional mode (octal perms) and chown: true (adds :U so podman re-chowns to the container's non-root uid). Used by the pgadmin preset to ship a servers.json + pgpass that autoconnects to lerd-postgres. Files re-render on every lerd service start so editing the YAML and restarting picks up changes.
  • Custom service connection_url: field — non-built-in databases now get the same "Open connection URL" link surface as the built-in mysql/postgres services. The detail panel renders a real <a> element pointing at mysql://, postgresql://, or mongodb:// so right-click "Copy link" works and left-click hands the URL to the user's registered DB client (DBeaver, TablePlus, Compass, etc.).
  • Recursive service startlerd service start <svc> now ensures every entry in depends_on is up first, recursively, in both the CLI and the Web UI. Pairs with the existing recursive stop that takes dependents down before the parent. Starting any preset that depends on a built-in (phpmyadmin, pgadmin) auto-starts the database.
  • Preset dependency gating at install time — installing a preset whose dependency is another custom service (e.g. mongo-express on mongo) is rejected with a clear error until the dependency is installed first. Built-in deps (mysql, postgres) are auto-satisfied. The Web UI's Add button is disabled with a matching amber "install mongo first" hint.
  • Database service quality-of-life suggestions — the detail panel of every database service (mysql, postgres, and an installed mongo) now shows a sky-blue suggestion banner offering to install its paired admin UI when missing. The banner is dismissable per-preset and the dismissal persists in localStorage. When the admin UI is installed, the header gains an Open phpMyAdmin / pgAdmin / Mongo Express button that auto-starts the admin service if needed.
  • Lerd health dot in the Web UI — the Lerd entry in the System list now reflects overall core health (green when DNS / nginx / watcher are all running, red when any is down, yellow when an update is available) instead of only the update flag. The lerd logo in the left rail gains a small yellow badge when an update is available and is clickable, jumping straight to the Lerd entry.
  • One-click update terminal — when an update is available, the Lerd entry exposes an "Open terminal & update" button that POSTs to the new loopback-only /api/lerd/update-terminal endpoint, which spawns the user's preferred terminal emulator (kitty / foot / alacritty / wezterm / ghostty / ptyxis / konsole / gnome-terminal / xfce4-terminal / tilix / terminator / xterm) running lerd update so the host can prompt for sudo and stream download progress.
  • Getting-started walkthroughs — new docs/getting-started/laravel.md, symfony.md, wordpress.md, and services.md pages plus a docs/usage/lifecycle.md reference covering how Lerd's units come up at boot and how start / stop / autostart interact.

Changed

  • autostart is now a single coherent switchcfg.Autostart.Disabled is the canonical source of truth for whether lerd comes up at login. Toggling it enables/disables every lerd-*.container quadlet (by adding/stripping the [Install] section so the podman generator stops emitting the default.target.wants symlink) and every lerd-*.service unit (UI, watcher, per-site worker/queue/schedule/horizon/reverb/stripe) together. Toggling does not stop or start anything currently running — the user is in the middle of working and a session-level switch should not yank infrastructure out from under them. Use lerd start / lerd stop for live state.
  • lerd autostart tray removed — the tray is now governed by the same single autostart switch as everything else. The standalone autostart tray subcommand and the lerd-autostart.service unit file are gone.
  • Service display labels — the Web UI now shows phpMyAdmin, pgAdmin, MySQL, PostgreSQL, Meilisearch, Mailpit, RustFS, MongoDB, Mongo Express, and Stripe Mock with their proper casing.

Fixed

  • Tray autostart was broken — the tray autostart path went through the now-removed lerd-autostart.service shim and stopped enabling on fresh installs. The unified autostart toggle now covers the tray too, the per-unit autostart toggle is wired up correctly, and lerd install honours the persisted autostart state.

[1.8.0] — 2026-04-09

Added

  • lerd lan:expose / lan:unexpose / lan:status — unified switch to share a lerd dev environment with another machine on the local network. Off by default; every container port now binds 127.0.0.1 (was 0.0.0.0 since v0.1.0), so untrusted wifi is safe out of the box. Service containers (mysql, postgres, redis, meilisearch, rustfs, mailpit) stay loopback-only even when LAN exposure is on; only nginx flips to 0.0.0.0, since Laravel apps in lerd-php-fpm reach services through the podman bridge regardless of host bind. Quadlets are rewritten centrally via podman.WriteQuadletDiff so flipping the switch only restarts units whose on-disk content actually changed.
  • Remote dashboard access — the dashboard at port 7073 is gated by two independent flags: cfg.LAN.Exposed is the top-level kill switch and cfg.UI.PasswordHash adds HTTP Basic auth on top. LAN clients only reach the dashboard when both are set; loopback always bypasses both. Stale credentials cannot survive lan:unexpose. The dashboard's "Remote dashboard access" card distinguishes active / inert / disabled states so the user sees when credentials are stored but blocked by lan:unexpose. UI feedback during a toggle streams NDJSON progress events from POST /api/lan/status; the card polls every 5s while on the System tab so CLI toggles are reflected without a page reload.
  • http://lerd.localhost as a usable bookmarklerd-nginx serves the static dashboard HTML, icons, and PWA manifest from the lerd.localhost vhost, with /api/* explicitly returning 444 so a LAN curl forging the Host header cannot reach lerd-ui through the proxy. The dashboard JS detects when it was loaded from lerd.localhost and rewrites all fetch, EventSource, and favicon img srcs to absolute http://localhost:7073 URLs so they hit lerd-ui directly over loopback.
  • lerd remote-setup — generates a one-shot 15-minute code and prints a curl one-liner the remote machine runs to install mkcert, trust the lerd root CA, and configure its resolver (NetworkManager+dnsmasq, systemd-resolved 254+, standalone dnsmasq, or macOS /etc/resolver). The endpoint is gated by token presence + RFC 1918 source IP + brute-force lockout. The bootstrap script's epilogue warns that the server IP is hardcoded into the resolver dropin and explains how to re-bootstrap if the server moves networks.
  • app_url field in .lerd.yaml and sites.yaml — new precedence chain for APP_URL: .lerd.yaml app_url (committed, shared across machines) > sites.yaml app_url (per-machine override) > the default <scheme>://<primary-domain> generator. lerd setup no longer overwrites a custom APP_URL on every run — set it once in .lerd.yaml and lerd respects it. The .lerd.yaml app_url is silently suppressed when its host points at a domain that the conflict filter dropped, so .env never ends up writing a hostname owned by another site.
  • Soft-fallback domain conflict handling — when lerd link or the parked-directory watcher tries to register a domain another site already owns, the conflicting domain is now filtered out (instead of failing the whole link) and a clear WARN line is printed naming the owning site. Surviving domains still register; if every domain conflicts, lerd falls back to a freshly generated <dirname>.<tld> with a numeric suffix. .lerd.yaml is never modified on disk — the original domains: list stays so the conflict is visible to the UI and self-heals on the next link if the owning site is removed.
  • Domain conflict UI surface — the site detail header's "+N more" pill now counts conflicted domains and shows an amber warning icon when present (hover reveals each conflicted entry with the owning site name). The Manage Domains modal renders conflicted entries at the top with a warning icon, the domain struck-through, a "used by <site>" pill, and a small trash button that removes the entry from .lerd.yaml only (no registry, vhost, or cert touched). The domain:remove server action detects conflict-filtered entries and routes them to a .lerd.yaml-only delete path.
  • [Remote Access] section in lerd status — new block showing LAN exposure state and dashboard remote-access state, with hints when off. Refactored into a testable printRemoteAccessStatus helper.
  • Tray "Expose to LAN" toggle — new menu item that shells out to lerd lan expose / unexpose, mirroring the autostart toggle.
  • Dynamic colour tray icon — white L when lerd is running, red L when stopped. The default flag flipped from --mono=true to --mono=false so the colour icon is what users see by default; mono mode is still available for OS-recoloured template icons. The icon's dark background was stripped so it's transparent on the panel.

Changed

  • Tray "Open Dashboard" opens http://lerd.localhost instead of the bare 127.0.0.1:7073 loopback URL. Tray API polling stays on loopback so the tray works before nginx is up.
  • Tray paused services render with a yellow dot instead of red, so user-initiated stops are visually distinct from broken services.
  • lerd doctor "linger enabled" check renamed to "systemd linger" so the WARN row no longer reads as if linger is in fact enabled.

Fixed

  • lerd uninstall left the tray running — the uninstall flow stopped and disabled all systemd units but never killed standalone tray processes (launched from the desktop file or lerd tray). The tray kept running after the binary was gone, with no way to dismiss it short of pkill. Uninstall now calls the existing killTray helper after the unit teardown.
  • lerd install hang when installing the Laravel installer — the installer prompted for the Laravel installer on every run and then shelled out through the composer shim, which routes through lerd php and depends on cwd-based PHP detection. When the install command runs from $HOME with no project metadata, detection fell back to cfg.PHP.DefaultVersion and handed composer to a possibly-missing container. Worse, composer global require triggers symfony/flex / plugin trust prompts which sat invisibly inside podman exec -t -i, making the whole step look stuck with no output. Fixed by skipping the prompt entirely when no PHP version is installed, and when it does run, bypassing the shim — picking a known-installed PHP (preferring the configured default), ensuring its FPM container is running, and podman exec'ing composer global require --no-interaction laravel/installer directly.

[1.7.1] — 2026-04-08

Added

  • Database picker in lerd init — the wizard's services step is now split into a single-choice Database select (sqlite / mysql / postgres) and a multi-select for everything else. The default is seeded from any database already in .lerd.yaml, then DB_CONNECTION in .env (or .env.example for fresh clones), falling back to SQLite. After the wizard completes, lerd env runs automatically so the choice immediately lands in .env — picking MySQL/PostgreSQL writes the connection vars and creates the project database (plus _testing), picking SQLite writes DB_CONNECTION=sqlite and creates database/database.sqlite if it's missing.
  • Runtime database prompt in lerd env — when run interactively on a Laravel project whose .env says DB_CONNECTION=sqlite and whose .lerd.yaml doesn't yet pick a database, lerd env now prompts for a deliberate choice (Keep SQLite / MySQL / PostgreSQL) and persists it so subsequent runs don't re-ask. Skipped automatically when stdin isn't a TTY (CI, MCP, scripted runs) and for frameworks with explicit env service rules (Symfony, WordPress, etc.) that don't use DB_CONNECTION.
  • db_set MCP tool — pick the database for a Laravel project from an AI assistant: db_set(database: "sqlite" | "mysql" | "postgres"). Persists the choice to .lerd.yaml (replacing any prior database — the choice is exclusive), rewrites the DB_ keys in .env, starts the service if needed, and creates the database (or the SQLite file). The companion env_setup tool's description now points at db_set so AI assistants know to call it before env_setup on fresh Laravel clones — env_setup alone leaves DB_CONNECTION=sqlite untouched.
  • SQLite as a first-class env-time choiceserviceEnvVars["sqlite"] now applies DB_CONNECTION=sqlite and DB_DATABASE=database/database.sqlite. The lerd env flow special-cases sqlite so it isn't treated as a podman service: no quadlet, no service_start, just the env vars and the file creation. The user's database choice in .lerd.yaml is authoritative — switching from mysql → sqlite (or vice versa) skips the auto-detection of the previous database in .env.

Fixed

  • vendor_bins / vendor_run missing from injected MCP skills — the new vendor/bin tooling shipped in v1.7.0 was registered with the MCP server but absent from the skill content that lerd mcp:inject writes into .claude/skills/lerd/SKILL.md and .junie/guidelines.md, so AI assistants weren't told the tools existed. Both files now describe the tools with examples for pest, phpunit, pint, phpstan, and rector. Re-run lerd mcp:inject in existing projects to pick up the updated skill content.

[1.7.0] — 2026-04-08

Added

  • Application log viewer in the UI — site detail view now has an App Logs tab that parses application log files into a structured table with level, date, and message columns, expandable to show full stacktraces. Frameworks declare log file locations and parser format via a new logs field in their YAML; Laravel defaults to storage/logs/*.log with Monolog parsing. Auto-selects the site with the most recent log activity on page load, refreshes every 5 seconds, and supports search filtering plus a Latest/All toggle. Entries display oldest-first (newest at the bottom), pinned to the bottom on every refresh, matching the streaming container/queue/worker log panes.
  • vendor/bin shortcuts and lerd test / lerd a aliases — any composer-installed binary in the project's vendor/bin is now callable directly as lerd <name> (e.g. lerd pest, lerd pint, lerd phpstan), routed through the project's PHP-FPM container with vendor/bin prepended to PATH. Built-in lerd commands always win on name collisions. Two new shortcuts: lerd a (alias for artisan) and lerd test (shortcut for artisan test). The same surface is exposed to MCP clients via vendor_bins (list) and vendor_run (execute). Closes #101.
  • Laravel installer shipped globallylerd install now offers to install laravel/installer as a global composer package and creates a laravel shim in BinDir routed through lerd php, so the laravel command works directly in the terminal the way Herd ships it. The prompt defaults to yes and runs before the parallel TUI to avoid stdin conflicts. Closes #98.
  • Site favicons in the UI — the UI detects favicon.ico/svg/png in each site's public directory and serves them via GET /api/sites/{domain}/favicon. The sites list and detail header now display the favicon when available, falling back to the status dot.

Changed

  • PHP and Node version selects deferred until loaded — the version dropdowns in the site detail view now show static placeholders while the version lists are still loading, preventing the browser from resetting selectedSite.php_version / node_version to an empty string and causing spurious change events.

Fixed

  • Dark mode dropdown readability — the PHP and Node version selectors now apply explicit option background and text colors so the dropdown menu is readable in dark mode.

[1.6.3] — 2026-04-06

Changed

  • Tray switched to libayatana-appindicator — the system tray now uses the actively maintained ayatana fork instead of the legacy libappindicator3. No behavior change; ayatana is the default backend in getlantern/systray and is already present on Ubuntu desktops.
  • lerd update defaults to yes — pressing Enter now confirms the update instead of cancelling.

Fixed

  • DNS broken on systems without NetworkManager — the resolved drop-in file was written with 0600 permissions (unreadable by systemd-resolved), breaking .test domain resolution on omarchy and similar systems. Fixed by setting correct permissions (0644) via sudoWriteFile.
  • Sudoers missing resolved paths — extended the sudoers drop-in to cover systemd-resolved config paths for passwordless install/start on resolved-only systems.

[1.6.2] — 2026-04-06

Fixed

  • MissingAppKeyException on fresh projectlerd env now generates APP_KEY directly in .env when vendor/ does not exist yet, instead of failing silently on artisan key:generate. This prevents Laravel's MissingAppKeyException during composer install post-install scripts in the lerd newlerd linklerd setup flow.
  • composer install using wrong PHP version in setuplerd setup now runs composer install inside the project's PHP-FPM container, matching the composer.json PHP constraint. Previously it used the host composer shim which could resolve to the global default PHP version.
  • PHP version detection from composer.json ignores installed versions — the constraint resolver now picks the highest installed PHP version satisfying the composer.json require.php constraint (e.g. ^8.3 with 8.3 and 8.4 installed → 8.4). Supports ^, ~, >=, <, ||, *, and AND constraints. Falls back to the literal minimum when no installed version matches.

[1.6.1] — 2026-04-06

Fixed

  • Fresh install missing default PHP-FPMlerd install now always builds and starts the default PHP version, even with no registered sites. Previously lerd new would fail on a fresh install because no PHP-FPM container existed.
  • Install not restoring serviceslerd install now restores service quadlets (mysql, redis, custom services) from .lerd.yaml, pulls missing images, and starts them. Workers no longer fail on reinstall because their dependencies are running.
  • Install not restoring workerslerd install now calls restoreSiteInfrastructure to recreate worker units from .lerd.yaml after services are started.
  • FPM not restored for sites using default PHP — both lerd install and lerd start now fall back to the configured default PHP version when a site has no explicit PHPVersion, instead of skipping it.
  • UI stripe toggle not syncing .lerd.yaml — toggling the Stripe listener from the web UI now writes the workers list to .lerd.yaml, matching the behaviour of all other worker toggles.
  • Uninstall spinner with no expandable output — replaced the StepRunner spinner (Ctrl+O did nothing) with the same step()/ok() output style used by install.

[1.6.0] — 2026-04-06

Added

  • Framework setup commands — framework definitions now support a setup field with one-off bootstrap commands (migrations, storage links, fixtures) shown in lerd setup. Laravel's hardcoded storage:link/migrate/db:seed steps are now part of the built-in framework definition. Custom frameworks define their own via YAML.
  • Conditional checks on workers and setup commands — both workers and setup entries support an optional check field (file or composer) to conditionally show them based on project dependencies (e.g. messenger worker only shown when symfony/messenger is installed).
  • Service version placeholders — framework env vars support , , , and placeholders, resolved from the running service image tag at lerd env time.
  • --setup flag for lerd framework add — define setup commands via CLI flags in addition to YAML.
  • Link modal streaming logs — the web UI link modal now streams lerd link and lerd env output line-by-line instead of showing only a spinner.
  • Domain modal success feedback — add/edit/remove domain operations in the web UI now show a flash message on success.
  • omarchy OS support — systems with systemd-resolved but no NetworkManager can now install and run lerd. The installer accepts either resolver.
  • Reverb prerequisite checklerd reverb:start and lerd reverb:stop now check for laravel/reverb in composer.json before proceeding, with install instructions and a link to the Laravel Broadcasting docs.

Changed

  • Worker state synced to .lerd.yaml — all worker start/stop commands (queue, schedule, reverb, horizon, stripe:listen, worker start/stop) now persist the active workers list in .lerd.yaml when the file exists. Previously worker start/stop and stripe:listen did not update the file.
  • lerd start restores site infrastructure — after an uninstall/reinstall cycle, lerd start reads .lerd.yaml from each active site and recreates missing FPM quadlets, service quadlets, and worker units automatically.
  • lerd install restores FPM quadlets — reinstalling now restores PHP-FPM quadlets for all PHP versions used by registered sites, not just the default version.
  • Improved lerd uninstall — stops all lerd-* systemd units (workers, stripe listeners, etc.) instead of only the hardcoded watcher and UI services. DNS teardown and the data-removal prompt now run before the step runner to avoid stdin conflicts.

Fixed

  • DNS teardown leaves stale DNS on virtual interfaceslerd uninstall now reverts all network interfaces that have lerd DNS configured (e.g. virbr0, vnet*), not just the default interface.
  • Internet DNS broken after uninstall — after reverting interfaces and restarting NetworkManager, lerd now explicitly pushes the DHCP-assigned upstream DNS servers so name resolution works immediately.
  • Domain modal stale state — the web UI domain modal now properly updates the domain list after add/edit/remove operations. The site list merge was matching by domain (which changes) instead of name (stable).
  • lerd env runs automatically in setuplerd env now runs at the start of lerd setup instead of being a selectable step, ensuring .env is configured before composer install triggers post-install scripts.
  • Definition conflict resolution — when .lerd.yaml and the local framework/service definition differ, lerd now offers a three-way choice: use .lerd.yaml version, use local definition, or skip. Both sync directions persist immediately.
  • Improved horizon/reverb error messages — error messages now include install commands and docs links instead of generic text.
  • Dynamic DNS resolver hintslerd doctor and lerd status now show the correct restart command based on the active resolver instead of always suggesting "restart NetworkManager".

Docs

  • Added contributing section to nav bar, stripe page to usage sidebar, troubleshooting to reference sidebar
  • Fixed placeholders being swallowed by VitePress (Vue template interpolation)
  • Replaced non-rendering mermaid chart with ASCII diagram on architecture page
  • Added reverb prerequisite note to commands reference
  • Updated requirements, architecture, and troubleshooting for systemd-resolved support

[1.5.1] — 2026-04-04

Fixed

  • Nginx fails to start when TLS certificates are missinglerd start now detects SSL vhosts referencing missing cert files before starting nginx, switches affected sites back to HTTP, and removes orphan SSL configs. Previously a single missing certificate would prevent all sites from loading.
  • Paused sites bypass landing page after updatelerd install (called by lerd update) was regenerating vhosts for all sites, overwriting paused landing pages with the full site config. Paused and ignored sites are now skipped during vhost regeneration.
  • Paused landing page redesigned — the paused page now matches the branded "Site Not Found" page with the Lerd logo, red accent, and Resume + Dashboard buttons. Uses a single shared HTML file instead of generating one per site.

[1.5.0] — 2026-04-04

Added

  • Multi-domain support — sites can now respond to multiple .test domains. Use lerd domain add, lerd domain remove, and lerd domain list to manage them. Domains are stored in .lerd.yaml and the certificate is reissued automatically when a domain is added to a secured site.
  • lerd env:check command — compare all .env files against .env.example and flag missing or extra keys. Exits non-zero when required keys are missing.
  • lerd check command — validate .lerd.yaml syntax, PHP version, Node version, services, frameworks, and workers before running setup. Reports OK/WARN/FAIL per field.
  • lerd which command — show the resolved PHP version, Node version, document root, and nginx config paths for the current site.
  • Port conflict detectionlerd start checks for port conflicts before starting containers and warns if another process is already using a required port.
  • lerd update --beta — update to the latest pre-release build from GitHub.
  • lerd update --rollback — revert to the previously installed version using the automatic backup.
  • Automatic PHP/Node version switching — the watcher monitors .lerd.yaml, .php-version, .node-version, and .nvmrc and automatically re-links the site when versions change.
  • Workers in lerd init — the wizard includes a workers step that pre-selects workers based on the framework and installed packages. Horizon is auto-detected from composer.json.
  • Setup prompt on link — when linking a site with workers configured in .lerd.yaml, lerd prompts to run lerd setup to install dependencies and start workers.
  • Branded error pages — requests to unlinked .test domains show a styled "Site Not Found" page with links to the dashboard instead of a generic browser error.
  • Failing worker visibilitylerd status shows failing and restarting workers across all sites. The web UI shows a pulsing red toggle and a "!" indicator on the log tab for failing workers.

Fixed

  • Crash-looping workers left running after unlinklerd unlink now detects and stops crash-looping workers for the site.
  • Paused sites counted in status workers section — paused sites are now excluded from the workers list in lerd status.
  • Paused sites counted in TLS checklerd status no longer flags TLS issues for paused or ignored sites.
  • Service container left behind on removelerd service remove now properly cleans up the Podman container.

[1.4.2] — 2026-04-03

Fixed

  • Paused sites counted in service badges and auto-stop logic — paused sites were included when counting how many sites use a service, so services stayed active and their site-count badges inflated even after all active sites were paused. Paused sites are now excluded from CountSitesUsingService and the badge tooltip list.

[1.4.1] — 2026-04-03

Fixed

  • 3-pane dashboard layout missing from v1.4.0 — the new icon rail, list panel, and full-height detail panel were lost during a merge conflict resolution. The correct UI is now restored.

[1.4.0] — 2026-04-03

Added

  • 3-pane dashboard layout — the UI is redesigned around a persistent icon rail (Sites, Services, System), a scrollable list panel, and a full-height detail panel. Logs fill remaining height rather than being capped at a fixed box. Works at any scale from 1 to 50+ sites. Mobile gets a full-screen list/detail with a bottom tab bar and a back button.
  • PHP-FPM auto-lifecycle — FPM containers for unused PHP versions are stopped automatically on lerd unlink and lerd start. Paused sites keep their FPM running. On lerd start, only versions referenced by at least one site are started. When a site is unpaused, its FPM container is guaranteed running before nginx is restored.
  • Manual FPM start/stop from the dashboard — unused PHP versions (no active sites) show a Stop button in the dashboard when running. Stopped unused versions are shown with a neutral badge rather than an error.
  • lerd start parallel spinner UI — start and stop operations now show a live per-unit progress display. All images required by units are checked and rebuilt or pulled before containers start.
  • Site pills on services — core services (MySQL, Mailpit, etc.) and worker-type services (Queue, Horizon, Reverb, etc.) show clickable site pills. Clicking a pill navigates directly to that site's settings.
  • Clickable PHP-FPM site pills — site pills on the PHP-FPM detail panel now navigate to the site's settings panel instead of opening the browser.
  • Instant system theme switching — when the theme is set to Auto, the dashboard switches between light and dark immediately as the OS preference changes, without a page reload.

Fixed

  • lerd status false errors for stopped unused PHP-FPM — stopped FPM containers for versions not referenced by any site are now reported as warnings, not errors.
  • MinIO migration prompt shown after already migrating to RustFS — the lerd update migration prompt now also checks whether the lerd-minio container is running, so users who have already migrated are not prompted again.
  • Pre-built PHP base images required ghcr.io login — lerd now always pulls base images anonymously to avoid authentication errors from expired or unrelated ghcr.io credentials.

[1.3.3] — 2026-04-02

Fixed

  • Broadcasting jobs fail when lerd env was run on a Reverb siteREVERB_HOST was set to the site domain (e.g. my-app.test), which resolves inside the PHP-FPM container to host.containers.internal (169.254.1.2). That address — the nginx proxy on the host — is not reachable from inside the container's network namespace, so every broadcast job failed with cURL error 7. REVERB_HOST, REVERB_PORT, and REVERB_SCHEME are now always written as localhost, REVERB_SERVER_PORT, and http so the queue worker connects to Reverb directly inside the same container. VITE_REVERB_HOST/PORT/SCHEME continue to use the site domain and external port for browser connections through nginx. Sites affected can be fixed by re-running lerd env.
  • Log lines repeating on SSE reconnect — when the browser reconnected to a log stream (network blip, tab restore) the entire history was replayed from the start. For systemd units the stream now emits the journalctl cursor as the SSE event id and resumes with --after-cursor on reconnect; for Podman containers a monotonic line counter is used and --tail 0 skips history on reconnect.

[1.3.2] — 2026-04-01

Fixed

  • Queue log streaming was a stale duplicate of the shared implementation — the /api/queue/<site>/logs SSE handler had its own inline copy of the log streaming logic instead of calling the shared streamUnitLogs helper used by every other worker (horizon, schedule, reverb, stripe). The duplicate is removed.

[1.3.1] — 2026-04-01

Fixed

  • PHP FPM fails to start on fresh installs — the shared hosts file (~/.local/share/lerd/hosts) is bind-mounted into every PHP-FPM container. If no site had ever been linked, the file did not exist and podman refused to start the container with statfs: no such file or directory. WriteFPMQuadlet now ensures the file is created before the container is started.

[1.3.0] — 2026-04-01

Added

  • Multiple Reverb sites without port collisions — when lerd env detects BROADCAST_CONNECTION=reverb, it auto-assigns a unique REVERB_SERVER_PORT per site starting at 8080 and incrementing for each additional site. reverb:start (including the UI toggle) also assigns and persists the port on first start if still missing, so the fix applies even when lerd env has not been re-run. The nginx WebSocket proxy uses the per-site port instead of the old hardcoded 8080. Fixes #47.
  • New MCP tools: db_import, db_create, php_list, php_ext, park, unpark — six new tools for AI agents covering database import from a SQL file, on-demand database creation, listing installed PHP versions, managing PHP extensions, and parking/unparking directories.
  • lerd whatsnew — new command that prints the changelog for the currently installed version. The changelog excerpt has been removed from lerd status and lerd doctor output.
  • Portable .lerd.yaml.lerd.yaml can now describe a site's full local environment (PHP version, Node version, framework, services, custom workers). Running lerd link in a project that has a .lerd.yaml applies all settings automatically, so cloning a project and running lerd link && lerd env is enough to reproduce the full environment. Closes #33.
  • Pre-built PHP base images — PHP images are now built on top of pre-built base images pulled from ghcr.io instead of compiling all extensions from source. First-install time drops from ~5 minutes to ~30 seconds. Closes #43.

[1.2.4] — 2026-03-31

Added

  • lerd php:rebuild accepts a version argument — pass a version (e.g. lerd php:rebuild 8.3) to rebuild only that PHP image instead of all installed versions.

Fixed

  • Inter-application .test domain resolution inside containers — HTTP/HTTPS requests from one site to another (e.g. booking.test calling staffing.test) were failing because .test domains resolved to 127.0.0.1 inside containers, which points to the container itself rather than the host Nginx. A shared hosts file (~/.local/share/lerd/hosts) is now bind-mounted into every PHP-FPM container at /etc/hosts with a 169.254.1.2 entry per linked site. Since it is a bind mount, lerd link and lerd unlink update all running containers instantly without a restart. Fixes #39.
  • Reverb proxy returns 502 after container restart — the Nginx location /app block used a bare hostname in proxy_pass, which Nginx resolves once at config load time. If the PHP-FPM container restarted and received a new IP, subsequent WebSocket and broadcast requests failed with 502. The proxy now uses a variable (set $reverb) to force per-request DNS resolution, matching how the FastCGI location already handles the FPM upstream.

[1.2.3] — 2026-03-31

Added

  • Horizon appears in the Services panel — when Laravel Horizon is running for a site it now shows up as its own entry in the Services panel (grouped under "Horizon"), with a stop button, live log stream, and a subtitle showing the site domain. Previously Horizon was only visible in the site detail view.
  • Starting Horizon stops the queue workerhorizon:start (CLI, UI, MCP) now automatically stops any running queue worker for the same site before starting Horizon, since the two must not run simultaneously.
  • lerd unlink stops all workers for the site — queue workers, Horizon, schedule workers, Reverb, Stripe listeners, and custom framework workers are all stopped before the site is unlinked.

Fixed

  • Tray no longer shows per-site workers — Reverb, Horizon, queue workers, schedule workers, Stripe listeners, and custom framework workers are filtered out of the tray menu. Only real infrastructure services (MySQL, Redis, Mailpit, etc.) are listed there.
  • lerd php can now run scripts outside $HOME — IDEs like PhpStorm write their validation scripts to /tmp and call php -d... /tmp/ide-phpinfo.php. The container only mounts $HOME, so those scripts were unreachable and produced an empty output ("Failed to parse validation script output"). runPhp now detects any argument that is an absolute path to a host file outside $HOME, reads it, and streams it to the container via stdin / /dev/stdin.
  • Horizon logs in the Services panel now stream the correct site — the logs URL for a Horizon service entry now routes to /api/horizon/{site}/logs (systemd journal) instead of the generic /api/logs/lerd-horizon-{site} endpoint that tried to use podman logs on a non-existent container.
  • Horizon log tab on the Sites panel no longer shows stale logs from a previous site — switching sites now properly closes and clears the Horizon log stream; clicking the Horizon tab reconnects to the correct site's stream.

[1.2.2] — 2026-03-31

Added

  • lerd init validates PHP version input — the PHP version prompt now rejects invalid input such as 8,5 or plain strings; only MAJOR.MINOR numeric format (e.g. 8.3) is accepted.
  • lerd init and lerd env detect services from .env.example — when .env is absent, service detection falls back to .env.example so a freshly cloned project is configured correctly before .env is created.
  • lerd env waits for services to be ready before creating databases and buckets — after starting MySQL, PostgreSQL, or RustFS, lerd now polls for readiness (mysqladmin ping / pg_isready / TCP dial) before attempting to create the database or bucket. Previously the create step could silently fail if the container had not finished initialising.
  • Automatic quadlet restoration for orphaned PHP FPM containerslerd php:list (and any command that calls ListInstalled) now scans podman ps -a for lerd-php*-fpm containers whose quadlet file is missing and restores it automatically, so users who lost their quadlet files do not need to reinstall PHP.

Fixed

  • lerd init installs PHP FPM with a progress indicator — when the required PHP FPM version is not yet installed, lerd init now shows a spinner rather than silently blocking. (PR #34)

[1.2.1] — 2026-03-31

Fixed

  • mcp:inject and mcp:enable-global fail on empty JSON config filesmergeMCPServersJSON now skips json.Unmarshal when the target file exists but is empty, preventing a spurious "unexpected end of JSON input" error. Affects ~/.ai/mcp/mcp.json, ~/.junie/mcp/mcp.json, and .mcp.json. (PR #31)
  • lerd new runs composer install with the wrong PHP versioncomposer create-project for Laravel now passes --no-install --no-plugins --no-scripts so dependency installation is deferred to lerd setup, where the correct PHP version is already active. (PR #28 by @voronkovich)
  • Duplicate export PATH entries written to .zshrc on repeated lerd installappendShellRC now checks whether the PATH line already exists before appending. (PR #30 by @voronkovich)
  • Redundant appendShellRC call writes a broken export PATH=":$PATH" line to .zshrc — the call with an empty binDir has been removed; ensureZshFpath already handles the fpath setup. (PR #29 by @voronkovich)

[1.2.0] — 2026-03-30

Added

  • lerd init — interactive wizard that writes PHP version, HTTPS preference, and required services to .lerd.yaml for project portability. On a machine with an existing .lerd.yaml, lerd init applies the saved config non-interactively, making new-machine setup a single command. lerd setup now runs the wizard as its first step, lerd link auto-secures when secured: true is set, and lerd env / lerd isolate / lerd secure all keep the file in sync.
  • lerd console — run a framework's interactive console (e.g. php artisan tinker for Laravel, or the console field from the framework YAML) inside the project container. Arguments are forwarded as-is.
  • console MCP tool — execute framework console commands from an AI assistant session. Resolves the correct binary via config.GetConsoleCommand so it works for any framework that defines a console field.
  • Cloudflare Tunnel backend for lerd share — pass --cloudflare to tunnel a site via cloudflared. Without the flag, lerd auto-detects between ngrok and Expose as before. The tunnel is routed through the host proxy to fix Host header and TLS SNI for secured sites.
  • pcov bundled in PHP-FPM images — pcov is now pre-installed via PECL in all lerd PHP-FPM images; lerd php:ext add pcov is no longer needed to run pest --coverage.
  • WebP support in PHP-FPM images — gd and imagick now include WebP support out of the box (PR #15 by @ReyArlena).
  • Connection URLs and hostname note in the dashboard — database service cards now show ready-to-use connection URLs alongside a note about the internal container hostname.

Fixed

  • Paused site vhosts overwritten on watcher restartscanWorktrees() now skips paused sites on startup; worktree vhost generation and nginx reloads triggered by .php-version changes are also skipped while a site is paused (registry is still updated for when the site is unpaused).
  • lerd console falls back to artisan for Laravel — when a Laravel project's framework YAML has no explicit console field, lerd console now correctly uses php artisan.

Internal

  • Unit tests for config, php, distro, and envfile packages.

[1.1.2] — 2026-03-30

Fixed

  • lerd install no longer hangs after "Adding shell PATH configuration" — the interactive MCP registration prompt has been removed. Run lerd mcp:enable-global manually after install to register the MCP server.
  • Dashboard URL in install completion message — now shows http://lerd.localhost instead of the raw http://127.0.0.1:7073 address.

[1.1.1] — 2026-03-30

Added

  • CI badge on README — the README now shows a live CI status badge linked to the ci.yml workflow.

Fixed

  • MCP registration prompt unresponsive when installing via pipelerd install reads the "Register lerd MCP globally?" prompt answer from /dev/tty instead of stdin. When the installer is run via a pipe (curl ... | sh), stdin is the pipe and fmt.Scan returns immediately with no input; opening /dev/tty directly reads from the actual terminal regardless of how the process was started.

Internal

  • Release workflow now gates on CI — the release.yml workflow runs build, test, vet, and format checks before invoking GoReleaser. A tag push on a broken commit will now fail before any artifacts are published.

[1.1.0] — 2026-03-30

Added

  • lerd new <name-or-path> — scaffold a new PHP project using the framework's create command. Defaults to Laravel (composer create-project laravel/laravel). Pass --framework=<name> to use any framework that defines a create field. Extra args can be forwarded to the scaffold command after --. The project_new MCP tool provides the same functionality for AI assistants.
  • create field in framework definitions — framework YAML files now support a create property (e.g. create: composer create-project symfony/skeleton). The target directory is appended automatically by lerd new. The --create flag was also added to lerd framework add.
  • project_new MCP tool — scaffold a new project from an AI assistant session. Accepts path (required), framework (default: laravel), and args (extra scaffold flags). Follow with site_link and env_setup to register and configure the new site.
  • lerd mcp:enable-global — registers the lerd MCP server at Claude Code user scope (and Windsurf / JetBrains Junie global configs) so lerd tools are available in every AI session without per-project configuration. During lerd install, if Claude Code is detected and lerd is not yet registered, the installer prompts to run this automatically.
  • site_php MCP tool — change the PHP version for a registered site from your AI assistant. Writes .php-version, updates the site registry, regenerates the nginx vhost, and reloads nginx in one call. The target FPM container must be running.
  • site_node MCP tool — change the Node.js version for a registered site. Writes .node-version and installs the version via fnm if not already present.
  • CWD fallback for MCP path resolution — the MCP server now falls back to the working directory Claude was opened in when LERD_SITE_PATH is not set. This means path can be omitted from artisan, composer, env_setup, site_link, db_export, and other tools when running in a global MCP session — just open Claude in the project directory.

Fixed

  • lerd setup npm step fails without a lockfile — the npm install step now runs npm ci when package-lock.json or yarn.lock is present, and falls back to npm install otherwise. Previously npm ci was always used, causing the step to fail on projects without a lockfile. (PR #5 by @voronkovich)
  • Duplicate PATH entry on lerd installadd_to_path in install.sh now checks the live $PATH before modifying shell rc files. If the install directory is already present, the function returns early and skips rc modification. (PR #7 by @voronkovich)
  • zsh completions moved to XDG directory — zsh completions are written to ~/.local/share/zsh/site-functions/_lerd instead of ~/.zfunc/_lerd, aligning with the XDG base directory convention. (PR #8 by @voronkovich)
  • .php-version changes not reflected in nginx — writing a .php-version file (via lerd isolate or directly) updated the queue worker but left the nginx vhost pointing at the old FPM socket. The watcher daemon now detects when the resolved PHP version changes, updates the site registry, regenerates the vhost, and reloads nginx automatically (debounced to 2 seconds).
  • PHP version resolution order.php-version now takes priority over composer.json's require.php constraint, matching the documented and intuitive precedence (explicit pin beats inferred constraint).

[1.0.4] — 2026-03-26

Fixed

  • .test domains unavailable from PHP-FPM containers — v1.0.3 fixed internet access by setting real upstream DNS servers (e.g. 192.168.0.x) on the lerd Podman network, but this caused aardvark-dns to skip systemd-resolved, breaking .test resolution from inside containers. lerd start and lerd install now use pasta's built-in DNS proxy at 169.254.1.1 (read from the rootless-netns info.json) as the aardvark-dns upstream. This address chains through systemd-resolved, which routes .test queries to lerd-dns and forwards all other queries to real upstream servers — giving containers both .test resolution and full internet access.
  • HTTPS to .test sites fails from inside PHP-FPM containers (cURL error 60) — PHP code making outbound HTTPS requests to local .test domains (e.g. Reverb broadcasting, internal API calls) received SSL certificate errors because the mkcert root CA was not trusted inside the container. The PHP-FPM image build now copies the mkcert root CA into the Alpine trust store (update-ca-certificates), so all .test HTTPS certificates are trusted. Existing images are automatically rebuilt on lerd update.
  • Reverb / queue / schedule workers not restarted after php:rebuild — when php:rebuild replaced and restarted the PHP-FPM containers, workers running inside those containers via podman exec (Reverb, queue, schedule) were killed by the BindsTo systemd dependency but not brought back up automatically. php:rebuild now explicitly restarts all such workers after the containers are back online.

[1.0.3] — 2026-03-26

Fixed

  • No internet access from PHP-FPM containers — on systems where /etc/resolv.conf points to a stub resolver (127.0.0.53 via systemd-resolved), aardvark-dns could not forward external DNS queries because the stub address is only reachable on the host's loopback, not from inside the container network namespace. lerd start and lerd install now detect the real upstream DNS servers (reading /run/systemd/resolve/resolv.conf first) and set them on the lerd Podman network so aardvark-dns forwards correctly.

[1.0.2] — 2026-03-25

Added

  • RustFS replaces MinIO — MinIO OSS is no longer maintained; lerd now ships RustFS as its built-in S3-compatible object storage service. RustFS exposes the same API and credentials (lerd / lerdpassword) so no application changes are needed. Closes #3.
  • lerd minio:migrate — one-command migration from an existing MinIO installation to RustFS. Stops the MinIO container, copies data to the RustFS data directory, removes the MinIO quadlet, updates config.yaml, and starts RustFS. The original MinIO data directory is preserved for manual cleanup.
  • Auto-migration prompt during lerd update — if a MinIO data directory is detected at update time, lerd offers to run the migration automatically before continuing.
  • lerd.localhost custom domain — the Lerd dashboard is now accessible at http://lerd.localhost (nginx proxies the domain to the UI service). lerd dashboard opens the new URL. .localhost resolves to 127.0.0.1 natively on all modern systems with no DNS configuration.
  • Installable PWA — the dashboard ships a web app manifest (/manifest.webmanifest) and SVG icons so it can be installed as a standalone app from Chrome or other PWA-capable browsers.

Fixed

  • 502 Bad Gateway on Inertia.js full-page refreshes — nginx vhost templates now include fastcgi_buffers 16 16k and fastcgi_buffer_size 32k, preventing upstream sent too big header errors caused by large FastCGI response headers (common on routes with heavy session/flash data).

[1.0.1] — 2026-03-25

Added

  • lerd shell — opens an interactive sh session inside the project's PHP-FPM container. The PHP version is resolved the same way as every other lerd command (.php-version, composer.json, global default). The working directory is set to the site root. If the site is paused, any services referenced in .env are started automatically before the shell opens.
  • Shell completions auto-installed on lerd install — fish completions are written to ~/.config/fish/completions/lerd.fish; zsh completions to ~/.zfunc/_lerd with the required fpath and compinit lines appended to .zshrc; bash completions to ~/.local/share/bash-completion/completions/lerd.
  • Pause/unpause propagates to git worktrees — when a site is paused, all its worktree checkouts also receive a paused nginx vhost with a Resume button. The button targets the parent site so clicking it unpauses both the parent and all worktrees at once. Unpausing restores all worktree vhosts and removes the paused HTML files.

Fixed

  • lerd park refuses to park a framework project root — if the target directory is itself a Laravel/framework project, lerd now prints a helpful message and suggests lerd link instead of silently misbehaving.
  • lerd park no longer registers framework subdirectories as sites — when a project root is accidentally used as a park directory, subdirectories like app/, vendor/, and public/ are now skipped with a warning rather than being registered as phantom sites.

[1.0.0] — 2026-03-25

Added

  • Laravel Horizon support — lerd auto-detects laravel/horizon in composer.json and provides dedicated lerd horizon:start / lerd horizon:stop commands that run php artisan horizon as a persistent systemd user service (lerd-horizon-{site}). When Horizon is detected, the Queue toggle in the web UI is replaced by a Horizon toggle, and a Horizon log tab appears in the site detail panel while Horizon is running. Pause/unpause correctly stops and resumes the Horizon service alongside other workers. MCP tools horizon_start and horizon_stop provide the same control to AI assistants.

  • Service dependencies (depends_on) — custom services can now declare which services they depend on. Starting a service with dependencies starts those dependencies first; starting a dependency automatically starts any services that depend on it; stopping a dependency cascade-stops its dependents first. Declare via the depends_on YAML field, the --depends-on flag on lerd service add, or the depends_on parameter in the service_add MCP tool.

  • lerd man — terminal documentation browser — browse and search the built-in docs without leaving the terminal. Opens an interactive TUI with arrow-key navigation, live filtering by title or content, and a scrollable markdown pager. Pass a page name to jump directly (e.g. lerd man sites). Set GLAMOUR_STYLE=light to override the default dark theme. Works in non-TTY mode too: lerd man | cat prints a table of contents and lerd man sites | cat prints raw markdown.

  • lerd about — new command that prints the version, build info, project URL, and copyright.

  • CLI commands auto-start services on paused sites — running php artisan, composer, lerd db:export, lerd db:import, or lerd db:shell in a paused site's directory automatically starts any services the site needs (MySQL, Redis, etc.) before executing. A notice is printed only when a service actually needs starting; if services are already running the command executes silently. The site stays paused — no vhost restore or worker restart.

  • lerd pause / lerd unpause — pause a site without unlinking it. lerd pause stops all running workers (queue, schedule, reverb, stripe, and any custom workers), replaces the nginx vhost with a static landing page, and auto-stops any services no longer needed by other active sites. The paused state persists across lerd start / lerd stop cycles. lerd unpause restores the vhost, restarts any services the site's .env references, and resumes all workers that were running before the pause. The landing page includes a Resume button that calls the lerd API directly so you can unpause from the browser.

  • lerd service pin / lerd service unpin — pin a service so it is never auto-stopped, even when no active sites reference it in their .env. Pinning immediately starts the service if it isn't already running. Unpin to restore normal auto-stop behaviour.

  • MCP site_pause / site_unpause tools — AI agents can pause and resume sites directly, enabling workflows like "pause all sites except the one I'm working on".

  • MCP service_pin / service_unpin tools — AI agents can pin services to keep them always available.

  • Extra ports on built-in serviceslerd service expose <service> <host:container> publishes an additional host port on any built-in service (mysql, redis, postgres, meilisearch, minio, mailpit). Mappings are persisted in ~/.config/lerd/config.yaml under services.<name>.extra_ports and applied on every start. The service is restarted automatically if running. Use --remove to delete a mapping. MCP tool service_expose provides the same capability.

  • Reverb nginx WebSocket proxy — when a site uses Laravel Reverb (detected via composer.json or BROADCAST_CONNECTION=reverb in .env), lerd now adds a /app location block to the nginx vhost that proxies WebSocket upgrade requests to the Reverb server running on port 8080 inside the PHP-FPM container. The block is added automatically on lerd link and on reverb:start.

  • Framework definitions — user-defined PHP framework YAML files at ~/.config/lerd/frameworks/<name>.yaml. Each definition describes detection rules, the document root, env file format, per-service env detection/variable injection, and background workers. lerd framework list/add/remove manage definitions from the CLI.

  • Framework workers — frameworks can define named background workers (e.g. messenger for Symfony, horizon or pulse for Laravel) that run as systemd user services inside the PHP-FPM container. lerd worker start <name> / lerd worker stop <name> / lerd worker list manage them.

  • Custom workers for Laravel — the built-in Laravel definition now has built-in queue, schedule, and reverb workers. Additional workers (e.g. Horizon, Pulse) can be added via lerd framework add laravel --from-file ...; they are merged on top of the built-in definition.

  • Generic lerd worker commandlerd worker start/stop/list works for any framework-defined worker. lerd queue:start, lerd schedule:start, and lerd reverb:start are now aliases for lerd worker start queue/schedule/reverb and work on any framework with those workers, not just Laravel.

  • Web UI: framework worker toggles — custom framework workers appear as indigo toggles in the Sites panel alongside queue/schedule/reverb. Each running worker shows a log tab in the site detail drawer and an indicator dot in the site list.

  • MCP worker_start / worker_stop / worker_list — start, stop, or list framework-defined workers for a site via the MCP server.

  • MCP framework_list / framework_add / framework_remove — manage framework definitions from an AI assistant. framework_add with name: "laravel" adds custom workers to the built-in Laravel definition.

  • MCP sites now includes framework and workers — each site entry now includes its framework name and a workers array with running status per worker.

  • Docs: Frameworks & Workers page — full documentation of the YAML schema, detection rules, worker definitions, and complete Symfony and WordPress examples.

  • Web UI: docs link — a "Docs" link in the dashboard navbar opens the documentation site.

Changed

  • lerd service list uses a compact two-column format — the Type column has been removed. Custom services show [custom] inline after their status. Inactive reason and depends on: info now appear as indented sub-lines, keeping the output narrow on small terminals.

  • lerd service list / lerd service status shows inactive reason — when a service is inactive, the output now includes a short note explaining why: (no sites using this service) for auto-stopped services, or (start with: lerd service start <name>) for manually stopped ones.

  • lerd logs accepts a site name as target — pass a registered site name to get logs for that site's PHP-FPM container (e.g. lerd logs my-project). Previously only nginx, service names, and PHP version strings were accepted.

  • lerd unlink auto-stops unused services — after unlinking a site, any services that were only needed by that site are automatically stopped (respecting pin and manually-started flags).

  • db:import and db:export accept a -d/--database flag — both commands now accept an optional --database / -d flag to target a specific database. When omitted the database name falls back to DB_DATABASE from the project's .env as before. The MCP db_export tool gains the same optional database argument.

  • lerd secure / lerd unsecure restart the Stripe listener — if a lerd stripe:listen service is active when HTTPS is toggled, it is automatically restarted with the updated forwarding URL so --forward-to stays in sync with the site's scheme.

  • MinIO: per-site bucket created by lerd env — when MinIO is detected, lerd env now creates a bucket named after the site handle (e.g. my_project), sets it to public access, and writes AWS_BUCKET=<site> and AWS_URL=http://localhost:9000/<site> into .env. Previously AWS_BUCKET was hardcoded to lerd and AWS_URL had no bucket path.

  • reverb:start regenerates the nginx vhost — running lerd reverb:start (or toggling Reverb in the web UI) now regenerates the site's nginx config and reloads nginx, ensuring the /app WebSocket proxy block is added to existing sites without requiring lerd link to be re-run.

  • lerd env sets correct Reverb connection valuesREVERB_HOST, REVERB_PORT, and REVERB_SCHEME are now derived from the site's domain and TLS state instead of hardcoded localhost:8080. VITE_REVERB_* vars are also written to match.

  • queue_start / schedule_start / reverb_start are no longer Laravel-only — these CLI commands and MCP tools now work for any framework that defines a worker with that name.

  • lerd env respects framework env configuration — uses the framework's configured env file, example file, format, url_key, and per-service detection rules instead of hardcoded Laravel paths.

  • lerd link / lerd park detect and record the framework — the detected framework name is stored in the site registry and shown in lerd sites.

Fixed

  • lerd php and lerd artisan no longer break MCP stdio transport — both commands now allocate a TTY (-t) only when stdin is a real terminal. When invoked by MCP or any other pipe-based tool, the TTY flag is omitted so stdin/stdout remain clean byte streams.

  • Reverb toggle no longer appears on projects that don't use Reverb — the UI previously showed the Reverb toggle for all Laravel sites because the built-in worker map always included reverb. It now gates on cli.SiteUsesReverb() (checks for laravel/reverb in composer.json or BROADCAST_CONNECTION=reverb in .env).

Removed

  • internal/laravel/detector.go — replaced by the generic config.DetectFramework / config.GetFramework system.

[0.9.1] — 2026-03-22

Added

  • MCP service_env tool — returns the recommended Laravel .env connection variables for any service (built-in or custom) as a key/value map. Agents can call service_env(name: "mysql") to inspect connection settings without running env_setup or modifying .env. Works for all six built-in services and any custom service registered via service_add.

Changed

  • lerd update does a fresh version check — bypasses the 24-hour update cache and always fetches the latest release tag from GitHub directly. After a successful update the cache is refreshed so lerd status and lerd doctor stop showing a stale "update available" notice.
  • lerd update ignores git-describe suffixes — dev/dirty builds (e.g. v0.9.0-dirty) are now treated as equal to the corresponding release when comparing versions, so locally-built binaries no longer trigger a spurious update prompt.

[0.9.0] — 2026-03-22

Added

  • lerd doctor command — full environment diagnostic. Checks podman, systemd user session, linger, quadlet/data dir writability, config validity, DNS resolution, port 80/443/5300 conflicts, PHP-FPM image presence, and update availability. Reports OK/FAIL/WARN per check with a hint for every failure and a summary line at the end.
  • lerd status shows watcher and update noticelerd-watcher is now included in the status output alongside DNS, nginx, and PHP-FPM. A highlighted banner is printed when a newer version is cached.
  • Background update checker — checks GitHub for a new release once per 24 hours; result is cached to ~/.local/share/lerd/update-check.json. Fetches relevant CHANGELOG sections between the current and latest version. Used by lerd status, lerd doctor, the web UI, and the system tray.
  • MCP status tool — returns structured JSON with DNS (ok + tld), nginx (running), PHP-FPM per version (running), and watcher (running). Recommended first call when a site isn't loading.
  • MCP doctor tool — runs the full lerd doctor diagnostic and returns the text report. Use when the user reports setup issues or unexpected behaviour.
  • Watcher structured logging — the watcher package now uses slog throughout. Set LERD_DEBUG=1 in the environment to enable debug-level output at runtime; watcher is otherwise silent except for WARN/ERROR events.
  • Web UI: Watcher card — the System tab now shows whether lerd-watcher is running. When stopped, a Start button appears to restart it without opening a terminal. The card also streams live watcher logs (DNS repair events, fsnotify errors, worktree timeouts) directly in the browser.
  • Web UI: grouped worker accordions — queue workers, schedule workers, Stripe listeners, and Reverb servers are now grouped into collapsible accordions on the Services tab. Click a group header to expand it; only one group is open at a time. Mobile pill navigation is split into core services + group toggle pills with expandable sub-rows.
  • Tray: update badge — the "Check for update..." menu item shows "⬆ Update to vX.Y.Z" when a new version is cached. Per-site workers (queue, schedule, Stripe, Reverb) are no longer listed in the tray services section.

Changed

  • lerd update shows changelog and asks for confirmation — before downloading anything, lerd update now fetches and prints the CHANGELOG sections for every version between the current and latest release, then prompts Update to vX.Y.Z? [y/N]. The update only proceeds on an explicit y/yes; pressing Enter or anything else cancels.

Fixed

  • lerd start now starts lerd-watcher — the watcher service was missing from the start sequence and could only be stopped by lerd quit, never started. lerd start now includes it alongside lerd-ui.

[0.8.2] — 2026-03-21

Fixed

  • 413 Request Entity Too Large on file uploads — nginx now sets client_max_body_size 0 (unlimited) in the http block, applied to all vhosts. lerd start also rewrites nginx.conf on every start so future config changes take effect without running lerd install.
  • MCP logs target accepts site domains — site names containing dots (e.g. astrolov.com) were incorrectly matched as PHP version strings, producing invalid container names. The PHP version check now requires the strict pattern \d+\.\d+.
  • MinIO AWS_URL set to public endpointAWS_URL is now http://localhost:9000 (browser-reachable) instead of http://lerd-minio:9000 (internal container hostname). AWS_ENDPOINT is unchanged and remains the internal address used by PHP.
  • Services page no longer blinks — the services list was polling every 5 seconds regardless of which tab was active, and showed a loading spinner on each poll. Polling now only runs while the services tab is visible, and the spinner only shows on the initial load.

Added

  • DNS health watcher — the lerd-watcher daemon now polls .test DNS resolution every 30 seconds. When resolution breaks, it waits for lerd-dns to be ready and re-applies the resolver configuration, replicating the repair performed by lerd start. Uses the configured TLD (dns.tld in global config, default test).
  • MCP logs target is optional — when target is omitted, logs for the current site's PHP-FPM container are returned (resolved from LERD_SITE_PATH). Specify target only to view a different service or site.

Changed

  • make install respects manually-stopped serviceslerd-ui, lerd-watcher, and lerd-tray are only restarted after install if they were already running. Services stopped via lerd quit are left stopped.

[0.8.1] — 2026-03-21

Fixed

  • MCP service_start / service_stop accept custom services — the MCP tool schema previously restricted the name field to an enum of built-in services, causing AI assistants to refuse to call these tools for custom services added via service_add. The enum constraint has been removed; any registered service name is now valid.

Changed

  • MCP SKILL and guidelines updatedsoketi removed from the built-in service list (dropped in v0.8.0); service_start/service_stop descriptions clarified to explicitly mention custom service support.

[0.8.0] — 2026-03-21

Added

  • lerd reverb:start / reverb:stop — runs the Laravel Reverb WebSocket server as a persistent systemd user service (lerd-reverb-<site>.service), executing php artisan reverb:start inside the PHP-FPM container. Survives terminal sessions and restarts on failure. Also available as lerd reverb start / lerd reverb stop.
  • lerd schedule:start / schedule:stop — runs the Laravel task scheduler as a persistent systemd user service (lerd-schedule-<site>.service), executing php artisan schedule:work. Also available as lerd schedule start / lerd schedule stop.
  • lerd dashboard — opens the Lerd dashboard (http://127.0.0.1:7073) in the default browser via xdg-open.
  • Auto-configure REVERB_* env varslerd env now generates REVERB_APP_ID, REVERB_APP_KEY, REVERB_APP_SECRET, and REVERB_HOST/PORT/SCHEME values when BROADCAST_CONNECTION=reverb is detected, using random secure values for secrets.
  • lerd setup runs storage:link — setup now runs php artisan storage:link when the site's storage/app/public directory is not yet symlinked.
  • lerd setup starts the queue worker — setup now starts queue:start as a final step when QUEUE_CONNECTION=redis is set in .env or .env.example.
  • Watcher triggers queue:restart on config changes — the watcher daemon monitors .env, composer.json, composer.lock, and .php-version in every registered site and signals php artisan queue:restart when any of those files change (debounced). This ensures queue workers reload after deploys or PHP version changes.
  • lerd start / stop manage schedule and reverblerd start and lerd stop now include all lerd-schedule-* and lerd-reverb-* service units in their start/stop sequences alongside queue workers and stripe listeners.
  • MCP tools for reverb, schedule, stripe — new reverb_start, reverb_stop, schedule_start, schedule_stop, and stripe_listen tools exposed via the MCP server.
  • Web UI: schedule and reverb per-site — the site detail panel shows whether the schedule worker and Reverb server are running, with start/stop buttons and live log streaming.
  • Web UI: stripe:stop action — the dashboard now supports stopping a stripe listener from the site action menu (was start-only).
  • WriteServiceIfChanged — internal helper that skips writing and running daemon-reload when a service unit's content is unchanged, preventing unnecessary Podman quadlet regeneration.
  • QueueRestartForSite — internal function that signals a graceful queue worker restart via php artisan queue:restart inside the PHP-FPM container.

Changed

  • Queue worker uses Restart=always — the lerd-queue-* service unit now restarts unconditionally (was Restart=on-failure), matching the behaviour of schedule and reverb services.
  • lerd.test dashboard vhost removedlerd install no longer generates an nginx proxy vhost for lerd.test. The dashboard is only accessible at http://127.0.0.1:7073. The lerd.test domain is no longer reserved and may be used for a regular site.
  • Web UI queue/stripe start is non-blockingqueue:start and stripe:listen site actions now run in a background goroutine so the HTTP response returns immediately rather than waiting for the service to start.

Removed

  • Soketi service removed — Soketi has been removed from Lerd's service list, config defaults, and env suggestions. Laravel Reverb (lerd reverb:start) is the recommended WebSocket solution.

[0.7.0] — 2026-03-21

Added

  • lerd quit command — fully shuts down Lerd: stops all containers and services (like lerd stop), then also stops the lerd-ui and lerd-watcher process units, and kills the system tray.
  • Start/Stop from the web UI — the dashboard now has Start and Stop buttons that call lerd start / lerd stop via new /api/lerd/start, /api/lerd/stop, and /api/lerd/quit API endpoints. The Start button is only shown when one or more core services (DNS, nginx, PHP-FPM) are not running.
  • lerd start resumes stripe listenerslerd-stripe-* services are now included in the start sequence alongside queue workers and the UI service.

Changed

  • Tray quit uses lerd quit — the tray's quit action now calls the new quit command instead of stop, ensuring a full shutdown including the UI and watcher processes. The menu item is renamed from "Stop Lerd & Quit" to "Quit Lerd".
  • lerd stop stops all services regardless of pause state — stop now shuts down all installed services including paused ones and stripe listeners, ensuring a clean shutdown every time.

Fixed

  • Log panel guards — clicking to open logs for FPM, nginx, DNS, or queue services no longer attempts to open a log stream when the service is not running.

0.6.0 — 2026-03-21

Added

  • Git worktree support — each git worktree checkout automatically gets its own subdomain (<branch>.<site>.test) with a dedicated nginx vhost. No manual steps required.
    • The watcher daemon detects git worktree add / git worktree remove in real time via fsnotify and generates or removes vhosts accordingly. It watches .git/ itself so it correctly re-attaches when .git/worktrees/ is deleted (last worktree removed) and re-created (new worktree added).
    • Startup scan generates vhosts for all existing worktrees across all registered sites.
    • EnsureWorktreeDeps — symlinks vendor/ and node_modules/ from the main repo into each worktree checkout, and copies .env with APP_URL rewritten to the worktree subdomain.
    • lerd sites shows worktrees indented under their parent site.
    • The web UI shows worktrees in the site detail panel with clickable domain links and an open-in-browser button.
    • A git-branch icon appears on the site button in the sidebar whenever the site has active worktrees.
  • HTTPS for worktrees — when a site is secured with lerd secure, all its worktrees automatically receive an SSL vhost that reuses the parent site's wildcard mkcert certificate (*.domain.test). No separate certificate is needed per worktree. Securing and unsecuring a site also updates APP_URL in each worktree's .env.
  • Catch-all default vhost (_default.conf) — any .test hostname that does not match a registered site returns HTTP 444 / rejects the TLS handshake, instead of falling through to the first alphabetical vhost.
  • stripe:listen as a background servicelerd stripe:listen now runs the Stripe CLI in a persistent systemd user service (lerd-stripe-<site>.service) rather than a foreground process. It survives terminal sessions and restarts on failure. lerd stripe:listen stop tears it down.
  • Service pause statelerd service stop now records the service as manually paused. lerd start and autostart on login skip paused services. lerd stop + lerd start restore the previous state: running services restart, manually stopped services stay stopped.
  • Queue worker Redis pre-flightlerd queue:start checks that lerd-redis is running when QUEUE_CONNECTION=redis is set in .env, and returns a friendly error with instructions rather than failing with a cryptic DNS error from PHP.

Fixed

  • Park watcher depth — the filesystem watcher no longer registers projects found in subdirectories of parked directories. Only direct children of a parked directory are eligible for auto-registration.
  • Nginx reload ordering for secure/unsecurelerd secure / lerd unsecure (and their UI/MCP equivalents) now save the updated secured flag to sites.yaml before reloading nginx. Previously a failed nginx reload would leave sites.yaml with a stale secured state, causing the watcher to regenerate the wrong vhost type on restart.
  • Tray always restarts on lerd start — any existing tray process is killed before relaunching, preventing duplicate tray instances after repeated lerd start calls.
  • FPM quadlet skip-write optimisationWriteFPMQuadlet skips writing and daemon-reloading when the quadlet content is unchanged. Unnecessary daemon-reloads caused Podman's quadlet generator to regenerate all service files, which could briefly disrupt lerd-dns and cause .test resolution failures.

[0.5.16] — 2026-03-20

Fixed

  • PHP-FPM image build on restricted Podman — fully qualify all base image names in the Containerfile (docker.io/library/composer:latest, docker.io/library/php:X.Y-fpm-alpine). Systems without unqualified-search registries configured in /etc/containers/registries.conf would fail with "short-name did not resolve to an alias".

[0.5.15] — 2026-03-20

Fixed

  • PHP-FPM image build on Podman — the Containerfile now declares FROM composer:latest AS composer-bin as an explicit stage before copying the composer binary. Podman (unlike Docker) does not auto-pull images referenced only in COPY --from, causing builds to fail with "no stage or image found with that name". This also affected lerd update and lerd php:rebuild in v0.5.14, leaving containers stopped if the build failed after the old image was removed.
  • Zero-downtime PHP-FPM rebuildlerd php:rebuild no longer removes the existing image before building. The running container stays up during the build; only the final systemctl restart causes a brief interruption. Force rebuilds now use --no-cache instead of rmi -f.
  • UI logs panel — clicking logs for a site whose PHP-FPM container is not running now shows a clean "container is not running" message instead of the raw podman error.
  • lerd php / lerd artisan — running these when the PHP-FPM container is stopped now returns a friendly error with the systemctl --user start command instead of a raw podman error.
  • lerd update ensures PHP-FPM is running — after applying infrastructure changes, lerd update now starts any installed PHP-FPM containers that are not running. Also fixed a cosmetic bug where "skipping rebuild" was printed even when a rebuild had just run.

[0.5.14] — 2026-03-20

Added

  • LERD_SITE_PATH in MCP configmcp:inject now embeds the project path as LERD_SITE_PATH in the injected MCP server config. The MCP server reads this at startup and uses it as the default path for artisan, composer, env_setup, db_export, and site_link, so AI assistants no longer need to pass an explicit path on every call.
  • .ai/mcp/mcp.json injectionmcp:inject now also writes into .ai/mcp/mcp.json (used by Windsurf and other MCP-compatible tools), in addition to .mcp.json and .junie/mcp/mcp.json.

[0.5.10] — 2026-03-20

Fixed

  • DNS race on install/updatelerd install (and by extension lerd update) now waits up to 15 seconds for the lerd-dns container to be ready before calling ConfigureResolver(). Previously, resolvectl was called immediately after the container restart, causing systemd-resolved to mark 127.0.0.1:5300 as failed and fall back to the DHCP DNS server, breaking .test resolution until lerd install was run again manually.

[0.5.8] — 2026-03-20

Fixed

  • GoReleaser archive — split amd64 and arm64 into separate archive definitions so lerd-tray (amd64-only) doesn't cause a binary count mismatch error

[0.5.7] — 2026-03-20

Fixed

  • Cross-distro tray compatibility — the main lerd binary is now fully static (CGO_ENABLED=0) and carries no shared library dependencies. A separate lerd-tray binary (built with CGO + libappindicator3) is shipped alongside it in the release tarball. At runtime lerd tray execs lerd-tray; if the helper is absent or libappindicator3.so.1 is missing the tray is silently skipped and everything else keeps working. Fixes startup failure on Fedora and other distros where libappindicator3 is not installed by default.

[0.5.6] — 2026-03-19

Added

  • Parallel build TUIlerd fetch and lerd php:rebuild now build PHP-FPM images in parallel with a compact spinner UI; press Ctrl+O to toggle per-job output
  • Service image pull TUIlerd service start shows a spinner while pulling the container image if it is not already present
  • Condensed uninstall outputlerd uninstall uses the same spinner UI for a cleaner experience

Changed

  • Install outputlerd install uses plain sequential output with a spinner only for the slow image pull and dnsmasq build steps; interactive sudo prompts (mkcert CA, DNS sudoers) are no longer affected by raw terminal mode
  • mkcert output indented — output from mkcert -install is indented to align with the surrounding install step lines
  • Spinner timer hidden when zero — the elapsed timer is omitted from spinner rows that complete in under one second

Fixed

  • PHP Containerfile — removed pdo_sqlite and sqlite3 from docker-php-ext-install; both are bundled in the PHP Alpine base image and including them caused a Cannot find config.m4 build error

[0.5.5] — 2026-03-19

Added

  • lerd php:ext add/remove/list — manage custom PHP extensions per version; extensions are persisted in config and included in every image rebuild
  • Expanded default FPM image — added bz2, calendar, dba, ldap, mysqli, pdo_sqlite, sqlite3, soap, shmop, sysvmsg, sysvsem, sysvshm, xsl (via docker-php-ext-install) plus igbinary and mongodb (via PECL); the default bundle now covers ~30 extensions for Herd-parity
  • Composer extension detectionlerd park / lerd link reads ext-* keys from composer.json and warns if any required extensions are missing from the image, with an actionable hint
  • lerd php:ini [version] — opens the per-version user php.ini in $EDITOR; the file is mounted into the FPM container at /usr/local/etc/php/conf.d/98-lerd-user.ini and created automatically with commented examples on first use

[0.5.4] — 2026-03-19

Added

  • Custom services: users can now define arbitrary OCI-based services without recompiling. Config lives at ~/.config/lerd/services/<name>.yaml.
    • lerd service add [file.yaml] — add from a YAML file or inline flags (--name, --image, --port, --env, --env-var, --data-dir, --detect-key, --detect-prefix, --init-exec, --init-container, --dashboard, --description)
    • lerd service remove <name> — stop (if running), remove quadlet and config; data directory preserved
    • lerd service list — shows built-in and custom services with a [custom] type column
    • lerd service start/stop — works for custom services
    • lerd start / lerd stop — includes installed custom services
    • lerd env — auto-detects custom services via env_detect, applies env_vars, runs site_init.exec
    • lerd status — includes custom services in the [Services] section
    • Web UI services tab — shows custom services with start/stop and dashboard link
    • System tray — shows custom services (slot pool expanded from 7 to 20)
  • / placeholders in env_vars and site_init.exec — substituted with the project site handle at lerd env time
  • site_init YAML block — runs a sh -c command inside the service container once per project when lerd env detects the service (for DB/collection creation, user setup, etc.)
  • dashboard field on custom services and built-in service responses — shows an "Open" button in the web UI when the service is active; dashboard URLs for built-ins (Mailpit, MinIO, Meilisearch) moved from hardcoded JS to the API response
  • README simplified — now a slim landing page pointing to the docs site; full documentation at geodro.github.io/lerd
  • Docs updateddocs/usage/services.md extended with full custom services reference

Fixed

  • Custom service data directory is now created automatically before starting (podman refused to mount a non-existent host path)
  • lerd service remove now checks unit status before stopping — skips stop if not running, and aborts removal if stop fails (prevents orphaned running containers)

0.5.3 — 2026-03-19

Fixed

  • Tray not restarting after lerd update: lerd install was killing the tray with pkill but only relaunching it when lerd-tray.service was enabled. If the tray was started directly (lerd tray), it was killed and never restarted. Now tracks whether the tray was running before the kill and relaunches it directly when systemd is not managing it.

0.5.2 — 2026-03-19

Fixed

  • lerd db:create and lerd db:shell were missing from the binary — cmd/lerd/main.go was not staged in the v0.5.1 commit

0.5.1 — 2026-03-19

Added

  • lerd db:create [name] / lerd db create [name]: creates a database and a <name>_testing database in one command. Name resolution: explicit argument → DB_DATABASE from .env → project name (site registry or directory). Reports "already exists" instead of failing when a database is present. Available for both MySQL and PostgreSQL.
  • lerd db:shell / lerd db shell: opens an interactive MySQL (mysql -uroot -plerd) or PostgreSQL (psql -U postgres) shell inside the service container, connecting to the project's database automatically. Replaces the need to run podman exec --tty lerd-mysql mysql … manually.

Changed

  • lerd env now creates a <name>_testing database alongside the main project database when setting up MySQL or PostgreSQL. Both databases report "already exists" if they were previously created.

0.5.0 — 2026-03-19

Added

  • System tray applet (lerd tray): a desktop tray icon for KDE, GNOME (with AppIndicator extension), waybar, and other SNI-compatible environments. The applet detaches from the terminal automatically and polls http://127.0.0.1:7073 every 5 seconds. Menu includes:
    • 🟢/🔴 overall running status with per-component nginx and DNS indicators
    • Open Dashboard — opens the web UI
    • Start / Stop Lerd toggle
    • Services section — lists all active services with 🟢/🔴 status; clicking a service starts or stops it
    • PHP section — lists all installed PHP versions; current global default is marked ✔; clicking switches the global default via lerd use
    • Autostart at login toggle — enables or disables lerd-autostart.service
    • Check for update — polls GitHub; if a newer version is found the item changes to "⬆ Update to vX.Y.Z" and clicking opens a terminal with a confirmation prompt before running lerd update
    • Stop Lerd & Quit — runs lerd stop then exits the tray
  • --mono flag for lerd tray: defaults to true (white monochrome icon); pass --mono=false for the red colour icon
  • lerd autostart tray enable/disable: registers/removes lerd-tray.service as a user systemd unit that starts the tray on graphical login
  • lerd start starts the tray: if lerd-tray.service is enabled it is started via systemd; otherwise, if no tray process is already running, lerd tray is launched directly
  • make build-nogui: headless build (CGO_ENABLED=0 -tags nogui) for CI or servers; lerd tray returns a clear error instead of failing to link

Changed

  • Build now requires CGO and libappindicator3 (libappindicator-gtk3 on Arch, libappindicator3-dev on Debian/Ubuntu, libappindicator-gtk3-devel on Fedora). The make build target sets CGO_ENABLED=1 -tags legacy_appindicator automatically.
  • lerd-autostart.service now declares After=graphical-session.target so the tray (which needs a display) is available when lerd start runs at login.
  • Web UI update flow: the "Update" button has been removed. When an update is available the UI now shows vX.Y.Z available — run lerd update in a terminal. The /api/update endpoint has been removed. This avoids silent failures caused by sudo steps in lerd install that require a TTY.
  • /api/status now includes a php_default field with the global default PHP version, used by the tray to mark the active version with ✔.

[0.4.3] — 2026-03-19

Fixed

  • DNS broken after install on Fedora (and other NM + systemd-resolved systems): the NetworkManager dispatcher script and ConfigureResolver() were calling resolvectl domain $IFACE ~test, which caused systemd-resolved to mark the interface as Default Route: no. This meant queries for anything outside .test (i.e. all internet DNS) had no route and were refused. Fixed by also passing ~. as a routing domain in both places — the interface now handles .test specifically via lerd's dnsmasq and remains the default route for all other queries.
  • .test DNS fails after reboot/restart: lerd start was calling resolvectl dns to point systemd-resolved at lerd-dns (port 5300) immediately after the container unit became active — but dnsmasq inside the container wasn't ready to accept connections yet. systemd-resolved would try port 5300, fail, mark it as a bad server, and fall back to the upstream DNS for the rest of the session. Fixed by waiting up to 10 seconds for port 5300 to accept TCP connections before calling ConfigureResolver().
  • Clicking a site URL after disabling HTTPS still opened the HTTPS version: the nginx HTTP→HTTPS redirect was a 301 (permanent), which browsers cache indefinitely. After disabling HTTPS, the browser would serve the cached redirect instead of hitting the server. Changed to 302 (temporary) so browsers always check the server, and disabling HTTPS takes effect immediately.

[0.4.2] — 2026-03-19

Changed

  • lerd setup detects the correct asset build command from package.json: instead of always suggesting npm run build, the setup step now reads scripts from package.json and picks the first available candidate in priority order: build (Vite / default), production (Laravel Mix), prod. The step label reflects the detected command (e.g. npm run production). If none of the candidates exist, the build step is omitted from the selector.

[0.4.1] — 2026-03-19

Fixed

  • lerd status TLS certificate check: certExpiry was passing raw PEM bytes directly to x509.ParseCertificate, which expects DER-encoded bytes. The fix decodes the PEM block first, so certificate expiry is read correctly and sites no longer show "cannot read cert" when the cert file exists and is valid.

[0.4.0] — 2026-03-19

Added

  • Xdebug toggle (lerd xdebug on/off [version]): enables or disables Xdebug per PHP version by rebuilding the FPM image with Xdebug installed and configured (mode=debug, start_with_request=yes, client_host=host.containers.internal, port 9003). The FPM container is restarted automatically. lerd xdebug status shows enabled/disabled for all installed versions.
  • lerd fetch [version...]: pre-builds PHP FPM images for the specified versions (or all supported: 8.1–8.5) so the first lerd use <version> is instant. Skips versions whose images already exist.
  • lerd db:import <file.sql> / lerd db:export [-o file]: import or export a SQL dump using the project's .env DB settings. Supports MySQL/MariaDB (lerd-mysql) and PostgreSQL (lerd-postgres). Also available as lerd db import / lerd db export.
  • lerd share [site]: exposes the current site publicly via ngrok or Expose. Auto-detects which tunnel tool is installed; use --ngrok or --expose to force one. Forwards to the local nginx port with the correct Host header so nginx routes to the right vhost.
  • lerd setup: interactive project bootstrap command — presents a checkbox list of steps (composer install, npm ci, lerd env, lerd mcp:inject, php artisan migrate, php artisan db:seed, npm run build, lerd secure, lerd open) with smart defaults based on project state. lerd link always runs first (mandatory, not in the list) to ensure the site is registered with the correct PHP version before any subsequent step. --all / -a runs everything without prompting (CI-friendly); --skip-open skips opening the browser.

Fixed

  • PHP version detection order: composer.json require.php now takes priority over .php-version, so projects declaring "php": "^8.4" in composer.json automatically use PHP 8.4 even if a stale .php-version file says otherwise. Explicit .lerd.yaml overrides still take top priority.
  • lerd link preserves HTTPS: re-linking a site that was already secured now regenerates the SSL vhost (not an HTTP vhost), so https:// continues to work after a re-link.
  • lerd link preserves secured flag: re-linking no longer resets a secured site to secured: false.
  • lerd secure / lerd unsecure directory name resolution: sites in directories with real TLDs (e.g. astrolov.com) are now resolved correctly by path lookup, so the commands no longer error with "site not found" when the directory name differs from the registered site name.

[0.3.0] — 2026-03-18

Added

  • lerd env command: copies .env.example.env if missing, detects which services the project uses, applies lerd connection values, starts required services, generates APP_KEY if missing, and sets APP_URL to the registered .test domain
  • lerd unsecure [name] command: removes the mkcert TLS cert and reverts the site to HTTP
  • lerd secure and lerd unsecure now automatically update APP_URL in the project's .env to https:// or http:// respectively
  • lerd install now installs a /etc/sudoers.d/lerd rule granting passwordless resolvectl dns/domain/revert — required for the autostart service which cannot prompt for a sudo password
  • PHP FPM images now include the gmp extension
  • MCP server (lerd mcp): JSON-RPC 2.0 stdio server exposing lerd as a Model Context Protocol tool provider for AI assistants (Claude Code, JetBrains Junie, and any MCP-compatible client). Tools: artisan, sites, service_start, service_stop, queue_start, queue_stop, logs
  • lerd mcp:inject: writes .mcp.json, .claude/skills/lerd/SKILL.md, and .junie/mcp/mcp.json into a project directory. Merges into existing mcpServers configs — other servers (e.g. laravel-boost, herd) are preserved unchanged
  • UI: queue worker toggle in the Sites tab — amber toggle to start/stop the queue worker per site; spinner while toggling; error text on failure; logs link opens the live log drawer for that worker when running
  • UI: Unlink button in the Sites tab — small red-bordered button that confirms, calls POST /api/sites/{domain}/unlink, and removes the site from the table client-side immediately
  • lerd unlink parked-site behaviour: unlinking a site under a parked directory now marks it as ignored in the registry instead of removing it, preventing the watcher from re-registering it on next scan. Running lerd link in the same directory clears the flag. Non-parked sites are still removed from the registry entirely
  • GET /api/sites filters out ignored sites so they are invisible in the UI
  • queue:start and queue:stop are now also available as API actions via POST /api/sites/{domain}/queue:start and POST /api/sites/{domain}/queue:stop, enabling UI and MCP control

Fixed

  • DNS .test routing now works correctly after autostart: resolvectl revert is called before re-applying per-interface DNS settings so systemd-resolved resets the current server to 127.0.0.1:5300; previously, resolved would mark lerd-dns as failed during boot (before it started) then fall back to the upstream DNS for all queries including .test, causing NXDOMAIN on every .test lookup
  • fnm install no longer prints noise to the terminal when a Node version is already installed

Changed

  • lerd start and lerd stop now start/stop containers in parallel — startup is noticeably faster on multi-container setups
  • lerd start now re-applies DNS resolver config on every invocation, ensuring .test routing is always correct after reboot or network changes
  • lerd park now skips already-registered sites instead of overwriting them, preserving settings such as TLS status and custom PHP version
  • lerd install completion message now shows both http://lerd.test and http://127.0.0.1:7073 as fallback
  • Composer is now stored as composer.phar; the composer shim runs it via lerd php
  • Autostart service now declares After=network-online.target and runs at elevated priority (Nice=-10)

[0.2.0] — 2026-03-17

Changed

  • UI completely redesigned: dark theme inspired by Laravel.com with near-black background, red accents, and top navbar replacing the sidebar
  • Light / Auto / Dark theme toggle added to the navbar; preference persists in localStorage

[0.1.66] — 2026-03-17

Fixed

  • lerd start now detects missing PHP FPM images (e.g. after podman rmi) and automatically rebuilds them before starting units
  • lerd status now reports image missing with a lerd php:rebuild <version> hint instead of just showing the container as not running

[0.1.65] — 2026-03-17

Fixed

  • PHP 8.5 FPM image now builds successfully: opcache is already compiled into PHP 8.5 so docker-php-ext-enable opcache is now a no-op (|| true); apk update is run before apk add to avoid stale index warnings; redis falls back to building from GitHub source when PECL fails

[0.1.64] — 2026-03-17

Fixed

  • redis and imagick PHP extensions now fall back to building from GitHub source when the PECL stable release doesn't compile against the current PHP API version (e.g. PHP 8.5) — redis is required so the build fails if both methods fail; imagick remains optional

[0.1.63] — 2026-03-17

Fixed

  • pecl install redis is now also non-fatal during PHP FPM image builds — the redis extension (like imagick) doesn't yet compile against PHP 8.5's new API; both extensions are best-effort and the build succeeds regardless

[0.1.62] — 2026-03-17

Fixed

  • PHP 8.5 image build no longer fails when the imagick PECL extension can't compile against the new PHP API — imagick is installed if available, silently skipped otherwise (redis is unaffected)

[0.1.61] — 2026-03-17

Fixed

  • Domains are now always lowercased — directory names like MyApp or custom --domain MyApp.test now consistently produce myapp.test

[0.1.60] — 2026-03-17

Fixed

  • All container volume mounts now include the :z SELinux relabeling option — on Fedora (and other SELinux-enforcing systems) dnsmasq and nginx containers were unable to read their config files, causing DNS and nginx to fail immediately after install
  • Home-directory volume mounts (nginx, PHP-FPM) use --security-opt=label=disable instead of :z to avoid recursively relabeling the user's home directory

0.1.53 — 2026-03-17

Fixed

  • lerd install now configures the system DNS resolver (writes NM dispatcher / applies resolvectl) only after lerd-dns is running — previously applying resolvectl dns <iface> 127.0.0.1:5300 before the dnsmasq container started routed all DNS through a non-existent server, breaking image pulls with "no such host" / "server misbehaving"

0.1.52 — 2026-03-17

Fixed

  • DNS resolution on Ubuntu (systemd-resolved + NetworkManager): NM overrides global resolved.conf drop-ins via DBUS so the DNS=127.0.0.1:5300 drop-in had no effect; now installs an NM dispatcher script (/etc/NetworkManager/dispatcher.d/99-lerd-dns) that calls resolvectl dns/domain per-interface on "up", and applies it immediately to the default interface
  • Upstream DNS servers in the dnsmasq config are now detected from the running system (/run/systemd/resolve/resolv.conf/etc/resolv.conf, skipping loopback/stub addresses) — no hardcoded IPs
  • lerd-dns.container now mounts ~/.local/share/lerd/dnsmasq into the container and uses --conf-dir instead of embedding all options in the Exec line

0.1.51 — 2026-03-17

Fixed

  • DNS resolution now works on systems using systemd-resolved (Ubuntu, etc.) — lerd install detects whether systemd-resolved is the active resolver and writes /etc/systemd/resolved.conf.d/lerd.conf with DNS=127.0.0.1:5300 and Domains=~test instead of configuring NetworkManager's embedded dnsmasq
  • lerd status PHP version hint no longer shows "8.5" — corrected to "8.4"

0.1.50 — 2026-03-17

Fixed

  • install.sh --local binary path is now validated before check_prerequisites runs — previously podman not being installed would cause die "podman is required" before the file-exists check, making bats test 23 fail in CI

0.1.49 — 2026-03-17

Fixed

  • install.sh ask() no longer causes CI test failures under set -euo pipefail when /dev/tty is unavailable — read </dev/tty now has 2>/dev/null || true so a missing tty is silently treated as "no"

0.1.48 — 2026-03-17

Fixed

  • All container images now use fully qualified names (docker.io/library/nginx:alpine, etc.) — Ubuntu's /etc/containers/registries.conf has no unqualified-search registries, causing short names to fail with exit code 125
  • lerd install now writes the lerd.test UI vhost before starting nginx so the dashboard is available on the very first start

0.1.47 — 2026-03-17

Fixed

  • lerd install now runs podman system migrate after installing podman on a fresh system to initialise Podman's storage before the first rootless container operation

0.1.46 — 2026-03-17

Fixed

  • Container images are now pre-pulled before daemon-reload / service start so the systemd 90 s default timeout is not exceeded on a fresh install pulling large images; TimeoutStartSec=300 added to both lerd-nginx.container and lerd-dns.container as an additional safeguard
  • lerd install no longer prints a spurious nginx reload [WARN] — the separate reload step was removed; RestartUnit already loads the latest config

0.1.45 — 2026-03-17

Fixed

  • install.sh ask() now reads from /dev/tty so prompts work correctly when the script is piped to bash (curl | bash); a missing tty falls back gracefully
  • install.sh now aborts with a clear error if podman is not found after the prerequisite install step

0.1.44 — 2026-03-17

Fixed

  • HTTP→HTTPS redirect in SSL vhosts changed from 301 (permanent, browser-cached) to 302 (temporary) so disabling HTTPS is not cached by the browser
  • Site domain links in the dashboard now use https:// when TLS is enabled and http:// otherwise

0.1.43 — 2026-03-17

Fixed

  • lerd install (and lerd update) no longer overwrites SSL vhosts with plain HTTP configs — sites with secured: true in sites.yaml now have their SSL vhost regenerated in-place during the vhost regeneration step
  • Sites table in the dashboard no longer flickers on background poll — the 5 s interval now updates existing row properties in-place instead of replacing the entire array; new/removed sites are still added/removed correctly

0.1.42 — 2026-03-17

Added

  • Sites tab now auto-refreshes every 5 seconds — PHP version, Node version, TLS status, and FPM running state stay current without a manual reload
  • Install Node version UI added to the Services tab — enter a version number and click Install to run fnm install in the background

0.1.41 — 2026-03-17

Fixed

  • lerd install now uses RestartUnit (instead of StartUnit) for all services so a re-run after lerd update picks up the new binary and any changed quadlet files
  • Installer bats tests updated: latest_version mocks updated for the redirect-based version check, certutil added to the --check prerequisite mock

0.1.40 — 2026-03-17

Fixed

  • Sites tab now shows the live PHP/Node version detected from disk (.php-version, .lerd.yaml, composer.json) instead of the stale value stored in sites.yaml; if the detected version differs, sites.yaml is updated automatically

0.1.39 — 2026-03-17

Added

  • PHP and Node columns in the Sites tab are now dropdowns — selecting a version writes .php-version / .node-version to the project directory, updates sites.yaml, regenerates the nginx vhost, and reloads nginx; available PHP versions come from installed FPM quadlets, Node versions from fnm list

0.1.38 — 2026-03-17

Fixed

  • HTTPS sites no longer return "File not found" — SecureSite was constructing a bare config.Site with only Domain and PHPVersion, leaving Path empty so the generated SSL vhost had root /public; it now receives the full site struct
  • fetchLatestVersion tests updated to use the redirect-based approach (fixes broken test suite after v0.1.34 change)

0.1.37 — 2026-03-17

Fixed

  • HTTPS toggle in Sites tab no longer returns "site not found" — the API was looking up sites by name but receiving the full domain; added FindSiteByDomain and switched the handler to use it
  • HTTPS column now shows a proper toggle switch instead of "On / Off" text buttons

0.1.36 — 2026-03-17

Fixed

  • lerd status no longer warns about all 7 services being inactive — it now only shows services that have a quadlet file on disk (i.e. were intentionally installed); uninstalled services are silently skipped with a single "No services installed" message if none are present

0.1.35 — 2026-03-17

Added

  • install.sh now checks for certutil (nss-tools) as a prerequisite and offers to install it automatically — without it mkcert cannot register the CA in Chrome/Firefox, causing ERR_CERT_AUTHORITY_INVALID on HTTPS sites
  • README documents certutil/nss-tools as a requirement with per-distro package names

0.1.34 — 2026-03-17

Fixed

  • Version detection in both lerd update and install.sh no longer uses the GitHub REST API — it now follows the https://github.com/{repo}/releases/latest HTML redirect to extract the tag from the URL; this endpoint is not rate-limited (60 req/hour limit on the API was causing "No releases found" / HTTP 403 for anyone who ran the installer more than a few times)

0.1.33 — 2026-03-17

Fixed

  • install.sh latest_version() now sends User-Agent: lerd-installer and Accept: application/vnd.github+json headers — GitHub's API returns 403 for unauthenticated requests without a User-Agent, which the script was silently treating as "no releases found"
  • install.sh cmd_uninstall now dynamically discovers units from quadlet files on disk (same fix as lerd uninstall)

0.1.32 — 2026-03-17

Fixed

  • lerd uninstall now stops and disables all services that were enabled at runtime (e.g. mailpit, soketi started from the UI dashboard) — the unit list is now derived dynamically from the quadlet files on disk instead of a hardcoded list, so nothing is left behind
  • lerd uninstall now also removes lerd-ui.service alongside lerd-watcher.service

0.1.31 — 2026-03-17

Fixed

  • lerd update no longer fails with "GitHub API returned HTTP 403" — the version check now sends a User-Agent: lerd-cli header, which GitHub requires for unauthenticated API requests

0.1.30 — 2026-03-17

Fixed

  • lerd update now restarts the lerd-ui systemd service after applying changes so the new binary is immediately picked up without manual intervention

0.1.29 — 2026-03-17

Added

  • HTTPS toggle in Sites tab — the TLS column is now a clickable button; clicking it calls POST /api/sites/{domain}/secure or unsecure, issues/removes the mkcert certificate, regenerates the nginx vhost, and reloads nginx inline without leaving the UI

Fixed

  • lerd secure no longer fails with "renaming SSL config: no such file or directory" — RemoveVhost was deleting both the HTTP and SSL config files before the rename; the command now only removes the HTTP config, then renames the SSL one into place
  • .env Copy button now works on plain HTTP (lerd.test) — navigator.clipboard.writeText requires HTTPS; added a document.execCommand('copy') fallback via a temporary off-screen textarea

0.1.28 — 2026-03-17

Added

  • Live logs drawer — click any site row in the dashboard to open a live streaming log panel at the bottom of the screen showing that site's PHP-FPM container output (podman logs -f); lines are colour-coded (red for errors/fatals, yellow for warnings/notices); auto-scrolls with a 500-line buffer; Clear and Close controls in the header
  • Env vars preview in Services tab — each service card now has a "Show .env / Hide .env" toggle that expands a syntax-highlighted code block with all the .env variables for that service, with a one-click Copy button in the header

Fixed

  • Service start from UI no longer fails with "Unit not found" after the first time a service quadlet is written — handleServiceAction now retries StartUnit up to 5 times with increasing delays (300 ms each) to give the systemd Quadlet generator time to register the new .service unit after daemon-reload
  • Removed stale "Copied to clipboard!" feedback element that was previously separate from the env preview Copy button

0.1.27 — 2026-03-17

Fixed

  • lerd update (and lerd install) no longer prompts for sudo if DNS is already configured — dns.Setup() now checks whether /etc/NetworkManager/conf.d/lerd.conf and /etc/NetworkManager/dnsmasq.d/lerd.conf already contain the correct content and skips all sudo steps if so; this makes updating from the UI dashboard work without any password prompt in the common case

0.1.26 — 2026-03-17

Fixed

  • lerd.test proxy vhost no longer uses resolver + set $upstream — nginx's resolver directive only works with DNS, but host.containers.internal is resolved via /etc/hosts inside the container; using a static proxy_pass http://host.containers.internal:7073 lets nginx resolve it correctly at startup

0.1.25 — 2026-03-17

Changed

  • lerd update no longer unconditionally rebuilds PHP-FPM images — it now computes a SHA-256 hash of the embedded Containerfile and only rebuilds if the hash differs from the one stored after the last successful build
  • Hash is stored to ~/.local/share/lerd/php-image-hash after lerd php:rebuild, lerd use <version>, and lerd park (first build)

0.1.24 — 2026-03-17

Fixed

  • lerd.test proxy vhost now uses host.containers.internal instead of the Podman network gateway IP — the gateway IP is typically blocked by the host firewall for connections from containers, while host.containers.internal is a Podman built-in that always routes to the host correctly

0.1.23 — 2026-03-17

Fixed

  • Dashboard service start now writes the Quadlet file and reloads systemd before calling systemctl start, fixing "Unit not found" error on first use
  • Service action errors are now returned as JSON with the error message and last 20 lines of journalctl logs
  • Frontend shows a loading spinner while toggling, "Started successfully" / "Stopped" flash on success, and an inline error with expandable logs on failure

0.1.22 — 2026-03-17

Fixed

  • lerd.test dashboard now reachable: UI server changed to listen on 0.0.0.0:7073 so nginx (running inside the Podman container) can reach it via the network gateway IP
  • lerd install now reloads nginx after writing the lerd.test proxy vhost so it takes effect immediately without a manual restart
  • lerd.test is now a reserved domain — lerd park silently skips any directory that would resolve to it, lerd link returns an error if the resolved domain is reserved

0.1.21 — 2026-03-17

Added

  • Lerd dashboard — browser UI available at http://lerd.test, served by lerd serve-ui as a persistent systemd user service (lerd-ui.service)
  • Dashboard shows three tabs: Sites (table with domain links, PHP/Node version, TLS badge, FPM status), Services (start/stop toggles, copy .env button per service), System (DNS, nginx, PHP-FPM health, auto-refreshes every 10 seconds)
  • Update flow built into the UI: "Check for update" button in sidebar checks GitHub releases; if an update is available shows the version and an "Update" button that runs lerd update
  • lerd install now writes and starts lerd-ui.service and generates the lerd.test nginx reverse proxy vhost; prints Dashboard: http://lerd.test on completion
  • lerd start / lerd stop include lerd-ui alongside DNS, nginx, and PHP-FPM

0.1.20 — 2026-03-17

Changed

  • lerd stop now also stops all installed services (those with a quadlet file) in addition to DNS, nginx, and PHP-FPM
  • lerd start now also starts all installed services

0.1.19 — 2026-03-17

Added

  • lerd php:rebuild — force-removes and rebuilds all installed PHP-FPM images; useful after a Containerfile change
  • lerd update now automatically runs lerd php:rebuild after lerd install so PHP-FPM image changes (new extensions, config tweaks) are applied on every update

0.1.18 — 2026-03-17

Added

  • lerd logs — show PHP-FPM container logs for the current project (auto-detects version)
  • lerd logs -f / --follow — tail logs in real time
  • lerd logs nginx — show nginx container logs
  • lerd logs <service> — show logs for any service (e.g. lerd logs mailpit)
  • lerd logs <version> — show logs for a specific PHP-FPM container (e.g. lerd logs 8.5)
  • PHP-FPM containers now route all PHP errors to stderr (catch_workers_output, log_errors, error_log=/proc/self/fd/2) so they appear in podman logs / lerd logs

0.1.17 — 2026-03-17

Added

  • mailpit service — local SMTP server with web UI at http://127.0.0.1:8025; catches all outgoing mail from Laravel apps
  • soketi service — self-hosted Pusher-compatible WebSocket server for Laravel Echo / broadcasting
  • PHP 8.5 support — lerd use 8.5 builds and starts the PHP 8.5 FPM container; default PHP version updated to 8.5

0.1.16 — 2026-03-17

Added

  • lerd php [args...] — runs PHP inside the correct versioned FPM container, detecting version from .php-version / composer.json / global default
  • lerd artisan [args...] — shortcut for lerd php artisan [args]
  • lerd node [args...] — runs Node via fnm with auto-detected version
  • lerd npm [args...] — runs npm via fnm with auto-detected version
  • lerd npx [args...] — runs npx via fnm with auto-detected version
  • lerd install now writes php, composer, node, npm, npx shims to ~/.local/share/lerd/bin/ so commands work directly from the terminal

0.1.15 — 2026-03-17

Fixed

  • Service .env variables now use container hostnames (lerd-mysql, lerd-redis, etc.) instead of 127.0.0.1 — PHP-FPM runs inside the lerd Podman network so 127.0.0.1 resolves to the container's own loopback, not the host

0.1.14 — 2026-03-17

Fixed

  • nginx resolver directive added to nginx.conf using the Podman network gateway so upstream container hostnames are re-resolved dynamically after FPM restarts (previously nginx cached the old IP and returned 502)
  • fastcgi_pass in vhost templates now uses a $fpm variable to force use of the resolver
  • lerd install now regenerates all registered site vhosts so template changes are applied immediately
  • PHP-FPM containers now use a locally built image (lerd-php{version}-fpm:local) with all Laravel-required extensions pre-installed: pdo_mysql, pdo_pgsql, bcmath, mbstring, xml, zip, gd, intl, opcache, pcntl, exif, sockets, redis, imagick
  • PHP-FPM images are built automatically on first lerd use <version> — subsequent runs reuse the cached image

0.1.13 — 2026-03-17

Changed

  • lerd service start / lerd service restart.env output is printed without leading whitespace for direct copy-paste

0.1.12 — 2026-03-17

Fixed

  • lerd service start <service> — automatically writes the quadlet file and reloads systemd before starting, so services work on first use without needing a prior lerd install

Changed

  • lerd service start and lerd service restart now print the recommended .env variables to add to your Laravel project after the service starts

0.1.11 — 2026-03-17

Added

  • lerd start — start DNS, nginx, and all installed PHP-FPM containers
  • lerd stop — stop DNS, nginx, and all installed PHP-FPM containers

0.1.10 — 2026-03-17

Fixed

  • Nginx and PHP-FPM containers now mount the user's home directory so project files are accessible inside the containers
  • nginx.conf — added user root; and changed pid/error_log to writable paths (/tmp/nginx.pid, stderr) so nginx starts correctly in rootless Podman without UserNS=keep-id
  • PHP-FPM pool now runs workers as root (-R flag + zz-lerd.conf override) so it can read project files in the home directory
  • ensureFPMQuadlet — always overwrites the quadlet file (previously skipped if it existed, leaving stale configs in place)
  • lerd install — now regenerates all existing PHP-FPM quadlets so config changes are applied without manual deletion
  • EnsureNginxConfig — always overwrites nginx.conf (previously skipped if file existed)

0.1.9 — 2026-03-17

Fixed

  • lerd-dns.container quadlet template was embedded from the wrong source directory (internal/podman/quadlets/) — the file still referenced andyshinn/dnsmasq with Network=host, causing the DNS container to fail with "Permission denied on port 53"; updated to the Alpine-based dnsmasq on port 5300 via published port
  • dns.Setup() and ensureUnprivilegedPorts()sudo subprocesses now have Stdin/Stdout/Stderr connected to the process terminal so password prompts display correctly instead of failing with "a terminal is required"

Added

  • lerd unpark [directory] — removes a parked directory and unlinks all sites registered from it

Changed

  • lerd park and lerd link — directory names with real TLDs (.com, .net, .org, .io, .ltd, etc.) now have the TLD stripped and remaining dots replaced with dashes before appending .test (e.g. admin.astrolov.comadmin-astrolov.test)
  • lerd use <version> / lerd status — PHP version detection now tracks FPM quadlet files instead of static CLI binaries, so lerd use 8.4 is immediately reflected in lerd status

0.1.8 — 2026-03-17

Fixed

  • lerd update now automatically runs lerd install after swapping the binary, so quadlet files, DNS config, sysctl settings and any other infrastructure changes are applied without the user having to run a second command

0.1.7 — 2026-03-17

Fixed

  • lerd-dns.container — removed Network=host and AddCapability=NET_ADMIN which both fail under rootless Podman; container now runs dnsmasq on port 5300 via a published port (127.0.0.1:5300:5300)
  • lerd install — now checks net.ipv4.ip_unprivileged_port_start and automatically sets it to 80 (with sudo) so rootless Podman can bind nginx to ports 80 and 443; also writes /etc/sysctl.d/99-lerd-ports.conf to persist across reboots

Changed

  • lerd status — every FAIL entry now shows an actionable hint (e.g. systemctl --user start lerd-nginx, lerd service start mysql, lerd use 8.4)

0.1.6 — 2026-03-17

Fixed

  • lerd install was calling dns.WriteDnsmasqConfig (writes only the container's local config) instead of dns.Setup(), which means /etc/NetworkManager/conf.d/lerd.conf and /etc/NetworkManager/dnsmasq.d/lerd.conf were never written and NetworkManager was never restarted — causing *.test DNS resolution to silently fail
  • dns.Setup() now prints a clear message before invoking sudo so users know why a password prompt appears

0.1.5 — 2026-03-17

Fixed

  • install.sh — definitively fixed the install: cannot stat '...\033[0m...' error by refactoring download_binary to accept a caller-supplied directory instead of returning a path via stdout; all output now goes directly to the terminal (stderr) and is never captured by command substitution

0.1.4 — 2026-03-17

Fixed

  • install.shinstall: cannot stat '...\033[0m...' error: download_binary was called inside $() command substitution so its info output was captured into the binary variable along with the path; all UI output in download_binary now goes to stderr, leaving only the path on stdout
  • install.sh — tar extraction errors inside download_binary now also go to stderr and produce a clean error message instead of polluting the captured path

0.1.3 — 2026-03-17

Fixed

  • install.shBASH_SOURCE[0]: unbound variable still occurred on bash versions where ${array[0]:-default} triggers set -u when the array itself is unset (not just empty); fixed by suspending nounset briefly with set +u before reading BASH_SOURCE

0.1.2 — 2026-03-17

Fixed

  • install.shBASH_SOURCE[0]: unbound variable crash when the script is piped to bash (curl|bash / wget|bash); BASH_SOURCE is unset in that execution context so it now defaults to $0

0.1.1 — 2026-03-17

Fixed

  • install.sh — replaced [[ ... ]] && main "$@" guard with if/fi so the script sources cleanly under set -euo pipefail (the && idiom exits with code 1 when the condition is false, which set -e treated as fatal)
  • install.shlatest_version no longer exits non-zero when the GitHub API returns no tag_name (e.g. curl failure or no releases yet)

0.1.0 — 2026-03-17

Initial release.

Added

Core

  • Single static Go binary built with Cobra
  • XDG-compliant config (~/.config/lerd/) and data (~/.local/share/lerd/) directories
  • Global config at ~/.config/lerd/config.yaml with sensible defaults
  • Per-project .lerd.yaml override support
  • Linux distro detection (Arch, Debian/Ubuntu, Fedora, openSUSE)
  • Build metadata injected at compile time: version, commit SHA, build date

Site management

  • lerd park [dir] — auto-discover and register all Laravel projects in a directory
  • lerd link [name] — register the current directory as a named site
  • lerd unlink — remove a site and clean up its vhost
  • lerd sites — tabular view of all registered sites

PHP

  • lerd install — one-time setup: directories, Podman network, binary downloads, DNS, nginx
  • lerd use <version> — set the global PHP version
  • lerd isolate <version> — pin PHP version per-project via .php-version
  • lerd php:list — list installed static PHP binaries
  • PHP version resolution order: .php-version.lerd.yamlcomposer.json → global default

Node

  • lerd isolate:node <version> — pin Node version per-project via .node-version
  • Node version resolution order: .nvmrc.node-versionpackage.json engines.node → global default
  • fnm bundled for Node version management

TLS

  • lerd secure [name] — issue a locally-trusted mkcert certificate for a site
  • Automatic HTTPS vhost generation
  • mkcert CA installed into system trust store on lerd install

Services

  • lerd service start|stop|restart|status|list — manage optional services
  • Bundled services: MySQL 8.0, Redis 7, PostgreSQL 16, Meilisearch v1.7, MinIO

Infrastructure

  • All containers run rootless on a dedicated lerd Podman network
  • Nginx and PHP-FPM as Podman Quadlet containers (auto-managed by systemd)
  • dnsmasq container for .test TLD resolution via NetworkManager
  • fsnotify-based watcher daemon (lerd-watcher.service) for auto-discovery of new projects

Diagnostics

  • lerd status — health overview: DNS, nginx, PHP-FPM containers, services, cert expiry
  • lerd dns:check — verify .test resolution

Lifecycle

  • lerd update — self-update from latest GitHub release (atomic binary swap)
  • lerd uninstall — stop all containers, remove units, binary, PATH entry, optionally data
  • Shell completion via lerd completion bash|zsh|fish

Installer (install.sh)

  • curl and wget support
  • Prerequisite checking with per-distro install prompts (pacman / apt / dnf / zypper)
  • Automatic lerd install invocation post-download
  • --update, --uninstall, --check flags
  • Installs as lerd-installer for later use

Released under the MIT License.